Skip to content
47 changes: 47 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
Client,
ClientCredentialsProvider,
createMiddleware,
CrossAppAccessProvider,
discoverAndRequestJwtAuthGrant,
PrivateKeyJwtProvider,
ProtocolError,
SdkError,
Expand Down Expand Up @@ -152,6 +154,51 @@ 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 source="../examples/client/src/clientGuide.examples.ts#auth_crossAppAccess"
const authProvider = new CrossAppAccessProvider({
assertion: async ctx => {
// ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn
const result = await discoverAndRequestJwtAuthGrant({
idpUrl: 'https://idp.example.com',
audience: ctx.authorizationServerUrl,
resource: ctx.resourceUrl,
idToken: await getIdToken(),
clientId: 'my-idp-client',
clientSecret: 'my-idp-secret',
scope: ctx.scope,
fetchFn: ctx.fetchFn
});
return result.jwtAuthGrant;
},
clientId: 'my-mcp-client',
clientSecret: 'my-mcp-secret'
});

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`:
- `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
30 changes: 30 additions & 0 deletions examples/client/src/clientGuide.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
Client,
ClientCredentialsProvider,
createMiddleware,
CrossAppAccessProvider,
discoverAndRequestJwtAuthGrant,
PrivateKeyJwtProvider,
ProtocolError,
SdkError,
Expand Down Expand Up @@ -135,6 +137,33 @@ async function auth_privateKeyJwt(pemEncodedKey: string) {
return transport;
}

/** Example: Cross-App Access (SEP-990 Enterprise Managed Authorization). */
async function auth_crossAppAccess(getIdToken: () => Promise<string>) {
//#region auth_crossAppAccess
const authProvider = new CrossAppAccessProvider({
assertion: async ctx => {
// ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn
const result = await discoverAndRequestJwtAuthGrant({
idpUrl: 'https://idp.example.com',
audience: ctx.authorizationServerUrl,
resource: ctx.resourceUrl,
idToken: await getIdToken(),
clientId: 'my-idp-client',
clientSecret: 'my-idp-secret',
scope: ctx.scope,
fetchFn: ctx.fetchFn
});
return result.jwtAuthGrant;
},
clientId: 'my-mcp-client',
clientSecret: 'my-mcp-secret'
});

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

// ---------------------------------------------------------------------------
// Using server features
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -513,6 +542,7 @@ void disconnect_streamableHttp;
void serverInstructions_basic;
void auth_clientCredentials;
void auth_privateKeyJwt;
void auth_crossAppAccess;
void callTool_basic;
void callTool_structuredOutput;
void callTool_progress;
Expand Down
63 changes: 60 additions & 3 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 @@ -307,7 +351,7 @@ export function selectClientAuthMethod(clientInformation: OAuthClientInformation
* @param params - URL search parameters to modify
* @throws {Error} When required credentials are missing
*/
function applyClientAuthentication(
export function applyClientAuthentication(
method: ClientAuthMethod,
clientInformation: OAuthClientInformation,
headers: Headers,
Expand Down Expand Up @@ -501,8 +545,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));
}

// Apply scope selection strategy (SEP-835):
// 1. WWW-Authenticate scope (passed via `scope` param)
// 2. PRM scopes_supported
Expand Down Expand Up @@ -562,6 +614,7 @@ async function authInternal(
metadata,
resource,
authorizationCode,
scope,
fetchFn
});

Expand Down Expand Up @@ -1401,21 +1454,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