Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,61 @@ For user-facing applications, implement the {@linkcode @modelcontextprotocol/cli

For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClientProvider.ts).

### Cross-App Access (Enterprise Managed Authorization)

{@linkcode @modelcontextprotocol/client!client/authExtensions.CrossAppAccessProvider | CrossAppAccessProvider} implements Enterprise Managed Authorization (SEP-990) for scenarios where users authenticate with an enterprise identity provider (IdP) and clients need to access protected MCP servers on their behalf.

This provider handles a two-step OAuth flow:
1. Exchange the user's ID Token from the enterprise IdP for a JWT Authorization Grant (JAG) via RFC 8693 token exchange
2. Exchange the JAG for an access token from the MCP server via RFC 7523 JWT bearer grant

```ts
import { CrossAppAccessProvider } from '@modelcontextprotocol/client';
import { discoverAndRequestJwtAuthGrant } from '@modelcontextprotocol/client/crossAppAccess';

const authProvider = new CrossAppAccessProvider({
// Callback to obtain JWT Authorization Grant
assertion: async (ctx) => {
// ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn
const result = await discoverAndRequestJwtAuthGrant({
idpUrl: 'https://idp.example.com',
audience: ctx.authorizationServerUrl, // MCP auth server
resource: ctx.resourceUrl, // MCP resource URL
idToken: await getMyIdToken(), // Your ID token acquisition
clientId: 'my-idp-client',
clientSecret: 'my-idp-secret',
scope: ctx.scope,
fetchFn: ctx.fetchFn
});
return result.jwtAuthGrant;
},

// MCP server credentials
clientId: 'my-mcp-client',
clientSecret: 'my-mcp-secret',
clientName: 'my-app' // Optional
});

const transport = new StreamableHTTPClientTransport(
new URL('http://localhost:3000/mcp'),
{ authProvider }
);
```

The `assertion` callback receives a context object with:
- `authorizationServerUrl` – The MCP server's authorization server (discovered automatically)
- `resourceUrl` – The MCP resource URL (discovered automatically)
- `scope` – Optional scope passed to `auth()` or from `clientMetadata`
- `fetchFn` – Fetch implementation to use for HTTP requests

For manual control over the token exchange steps, use the Layer 2 utilities from `@modelcontextprotocol/client/crossAppAccess`:
- `requestJwtAuthorizationGrant()` – Exchange ID Token for JAG at IdP
- `discoverAndRequestJwtAuthGrant()` – Discovery + JAG acquisition
- `exchangeJwtAuthGrant()` – Exchange JAG for access token at MCP server

> [!NOTE]
> See [RFC 8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693), [RFC 7523 (JWT Bearer Grant)](https://datatracker.ietf.org/doc/html/rfc7523), and [RFC 9728 (Resource Discovery)](https://datatracker.ietf.org/doc/html/rfc9728) for the underlying OAuth standards.

## Tools

Tools are callable actions offered by servers — discovering and invoking them is usually how your client enables an LLM to take action (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview).
Expand Down
61 changes: 59 additions & 2 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,50 @@ export interface OAuthClientProvider {
*/
prepareTokenRequest?(scope?: string): URLSearchParams | Promise<URLSearchParams | undefined> | undefined;

/**
* Saves the authorization server URL after RFC 9728 discovery.
* This method is called by {@linkcode auth} after successful discovery of the
* authorization server via protected resource metadata.
*
* Providers implementing Cross-App Access or other flows that need access to
* the discovered authorization server URL should implement this method.
*
* @param authorizationServerUrl - The authorization server URL discovered via RFC 9728
*/
saveAuthorizationServerUrl?(authorizationServerUrl: string): void | Promise<void>;

/**
* Returns the previously saved authorization server URL, if available.
*
* Providers implementing Cross-App Access can use this to access the
* authorization server URL discovered during the OAuth flow.
*
* @returns The authorization server URL, or `undefined` if not available
*/
authorizationServerUrl?(): string | undefined | Promise<string | undefined>;

/**
* Saves the resource URL after RFC 9728 discovery.
* This method is called by {@linkcode auth} after successful discovery of the
* resource metadata.
*
* Providers implementing Cross-App Access or other flows that need access to
* the discovered resource URL should implement this method.
*
* @param resourceUrl - The resource URL discovered via RFC 9728
*/
saveResourceUrl?(resourceUrl: string): void | Promise<void>;

/**
* Returns the previously saved resource URL, if available.
*
* Providers implementing Cross-App Access can use this to access the
* resource URL discovered during the OAuth flow.
*
* @returns The resource URL, or `undefined` if not available
*/
resourceUrl?(): string | undefined | Promise<string | undefined>;

/**
* Saves the OAuth discovery state after RFC 9728 and authorization server metadata
* discovery. Providers can persist this state to avoid redundant discovery requests
Expand Down Expand Up @@ -497,8 +541,16 @@ async function authInternal(
});
}

// Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider)
await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl));

const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);

// Save resource URL for providers that need it (e.g., CrossAppAccessProvider)
if (resource) {
await provider.saveResourceUrl?.(String(resource));
}

// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
if (!clientInformation) {
Expand Down Expand Up @@ -550,6 +602,7 @@ async function authInternal(
metadata,
resource,
authorizationCode,
scope,
fetchFn
});

Expand Down Expand Up @@ -1389,21 +1442,25 @@ export async function fetchToken(
metadata,
resource,
authorizationCode,
scope,
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
resource?: URL;
/** Authorization code for the default `authorization_code` grant flow */
authorizationCode?: string;
/** Optional scope parameter from auth() options */
scope?: string;
fetchFn?: FetchLike;
} = {}
): Promise<OAuthTokens> {
const scope = provider.clientMetadata.scope;
// Prefer scope from options, fallback to provider.clientMetadata.scope
const effectiveScope = scope ?? provider.clientMetadata.scope;

// Use provider's prepareTokenRequest if available, otherwise fall back to authorization_code
let tokenRequestParams: URLSearchParams | undefined;
if (provider.prepareTokenRequest) {
tokenRequestParams = await provider.prepareTokenRequest(scope);
tokenRequestParams = await provider.prepareTokenRequest(effectiveScope);
}

// Default to authorization_code grant if no custom prepareTokenRequest
Expand Down
Loading
Loading