Skip to content

Commit 8a34337

Browse files
authored
Merge branch 'main' into fix/validate-client-metadata-url
2 parents e0ce1cf + ccb78f2 commit 8a34337

File tree

16 files changed

+1750
-37
lines changed

16 files changed

+1750
-37
lines changed

docs/client.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
Client,
2020
ClientCredentialsProvider,
2121
createMiddleware,
22+
CrossAppAccessProvider,
23+
discoverAndRequestJwtAuthGrant,
2224
PrivateKeyJwtProvider,
2325
ProtocolError,
2426
SdkError,
@@ -152,6 +154,51 @@ For user-facing applications, implement the {@linkcode @modelcontextprotocol/cli
152154

153155
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).
154156

157+
### Cross-App Access (Enterprise Managed Authorization)
158+
159+
{@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.
160+
161+
This provider handles a two-step OAuth flow:
162+
1. Exchange the user's ID Token from the enterprise IdP for a JWT Authorization Grant (JAG) via RFC 8693 token exchange
163+
2. Exchange the JAG for an access token from the MCP server via RFC 7523 JWT bearer grant
164+
165+
```ts source="../examples/client/src/clientGuide.examples.ts#auth_crossAppAccess"
166+
const authProvider = new CrossAppAccessProvider({
167+
assertion: async ctx => {
168+
// ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn
169+
const result = await discoverAndRequestJwtAuthGrant({
170+
idpUrl: 'https://idp.example.com',
171+
audience: ctx.authorizationServerUrl,
172+
resource: ctx.resourceUrl,
173+
idToken: await getIdToken(),
174+
clientId: 'my-idp-client',
175+
clientSecret: 'my-idp-secret',
176+
scope: ctx.scope,
177+
fetchFn: ctx.fetchFn
178+
});
179+
return result.jwtAuthGrant;
180+
},
181+
clientId: 'my-mcp-client',
182+
clientSecret: 'my-mcp-secret'
183+
});
184+
185+
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
186+
```
187+
188+
The `assertion` callback receives a context object with:
189+
- `authorizationServerUrl` – The MCP server's authorization server (discovered automatically)
190+
- `resourceUrl` – The MCP resource URL (discovered automatically)
191+
- `scope` – Optional scope passed to `auth()` or from `clientMetadata`
192+
- `fetchFn` – Fetch implementation to use for HTTP requests
193+
194+
For manual control over the token exchange steps, use the Layer 2 utilities from `@modelcontextprotocol/client`:
195+
- `requestJwtAuthorizationGrant()` – Exchange ID Token for JAG at IdP
196+
- `discoverAndRequestJwtAuthGrant()` – Discovery + JAG acquisition
197+
- `exchangeJwtAuthGrant()` – Exchange JAG for access token at MCP server
198+
199+
> [!NOTE]
200+
> 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.
201+
155202
## Tools
156203

157204
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).

examples/client/src/clientGuide.examples.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
Client,
1515
ClientCredentialsProvider,
1616
createMiddleware,
17+
CrossAppAccessProvider,
18+
discoverAndRequestJwtAuthGrant,
1719
PrivateKeyJwtProvider,
1820
ProtocolError,
1921
SdkError,
@@ -135,6 +137,33 @@ async function auth_privateKeyJwt(pemEncodedKey: string) {
135137
return transport;
136138
}
137139

140+
/** Example: Cross-App Access (SEP-990 Enterprise Managed Authorization). */
141+
async function auth_crossAppAccess(getIdToken: () => Promise<string>) {
142+
//#region auth_crossAppAccess
143+
const authProvider = new CrossAppAccessProvider({
144+
assertion: async ctx => {
145+
// ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn
146+
const result = await discoverAndRequestJwtAuthGrant({
147+
idpUrl: 'https://idp.example.com',
148+
audience: ctx.authorizationServerUrl,
149+
resource: ctx.resourceUrl,
150+
idToken: await getIdToken(),
151+
clientId: 'my-idp-client',
152+
clientSecret: 'my-idp-secret',
153+
scope: ctx.scope,
154+
fetchFn: ctx.fetchFn
155+
});
156+
return result.jwtAuthGrant;
157+
},
158+
clientId: 'my-mcp-client',
159+
clientSecret: 'my-mcp-secret'
160+
});
161+
162+
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
163+
//#endregion auth_crossAppAccess
164+
return transport;
165+
}
166+
138167
// ---------------------------------------------------------------------------
139168
// Using server features
140169
// ---------------------------------------------------------------------------
@@ -513,6 +542,7 @@ void disconnect_streamableHttp;
513542
void serverInstructions_basic;
514543
void auth_clientCredentials;
515544
void auth_privateKeyJwt;
545+
void auth_crossAppAccess;
516546
void callTool_basic;
517547
void callTool_structuredOutput;
518548
void callTool_progress;

packages/client/src/client/auth.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,50 @@ export interface OAuthClientProvider {
184184
*/
185185
prepareTokenRequest?(scope?: string): URLSearchParams | Promise<URLSearchParams | undefined> | undefined;
186186

187+
/**
188+
* Saves the authorization server URL after RFC 9728 discovery.
189+
* This method is called by {@linkcode auth} after successful discovery of the
190+
* authorization server via protected resource metadata.
191+
*
192+
* Providers implementing Cross-App Access or other flows that need access to
193+
* the discovered authorization server URL should implement this method.
194+
*
195+
* @param authorizationServerUrl - The authorization server URL discovered via RFC 9728
196+
*/
197+
saveAuthorizationServerUrl?(authorizationServerUrl: string): void | Promise<void>;
198+
199+
/**
200+
* Returns the previously saved authorization server URL, if available.
201+
*
202+
* Providers implementing Cross-App Access can use this to access the
203+
* authorization server URL discovered during the OAuth flow.
204+
*
205+
* @returns The authorization server URL, or `undefined` if not available
206+
*/
207+
authorizationServerUrl?(): string | undefined | Promise<string | undefined>;
208+
209+
/**
210+
* Saves the resource URL after RFC 9728 discovery.
211+
* This method is called by {@linkcode auth} after successful discovery of the
212+
* resource metadata.
213+
*
214+
* Providers implementing Cross-App Access or other flows that need access to
215+
* the discovered resource URL should implement this method.
216+
*
217+
* @param resourceUrl - The resource URL discovered via RFC 9728
218+
*/
219+
saveResourceUrl?(resourceUrl: string): void | Promise<void>;
220+
221+
/**
222+
* Returns the previously saved resource URL, if available.
223+
*
224+
* Providers implementing Cross-App Access can use this to access the
225+
* resource URL discovered during the OAuth flow.
226+
*
227+
* @returns The resource URL, or `undefined` if not available
228+
*/
229+
resourceUrl?(): string | undefined | Promise<string | undefined>;
230+
187231
/**
188232
* Saves the OAuth discovery state after RFC 9728 and authorization server metadata
189233
* discovery. Providers can persist this state to avoid redundant discovery requests
@@ -307,7 +351,7 @@ export function selectClientAuthMethod(clientInformation: OAuthClientInformation
307351
* @param params - URL search parameters to modify
308352
* @throws {Error} When required credentials are missing
309353
*/
310-
function applyClientAuthentication(
354+
export function applyClientAuthentication(
311355
method: ClientAuthMethod,
312356
clientInformation: OAuthClientInformation,
313357
headers: Headers,
@@ -504,8 +548,16 @@ async function authInternal(
504548
});
505549
}
506550

551+
// Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider)
552+
await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl));
553+
507554
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
508555

556+
// Save resource URL for providers that need it (e.g., CrossAppAccessProvider)
557+
if (resource) {
558+
await provider.saveResourceUrl?.(String(resource));
559+
}
560+
509561
// Apply scope selection strategy (SEP-835):
510562
// 1. WWW-Authenticate scope (passed via `scope` param)
511563
// 2. PRM scopes_supported
@@ -565,6 +617,7 @@ async function authInternal(
565617
metadata,
566618
resource,
567619
authorizationCode,
620+
scope,
568621
fetchFn
569622
});
570623

@@ -1427,21 +1480,25 @@ export async function fetchToken(
14271480
metadata,
14281481
resource,
14291482
authorizationCode,
1483+
scope,
14301484
fetchFn
14311485
}: {
14321486
metadata?: AuthorizationServerMetadata;
14331487
resource?: URL;
14341488
/** Authorization code for the default `authorization_code` grant flow */
14351489
authorizationCode?: string;
1490+
/** Optional scope parameter from auth() options */
1491+
scope?: string;
14361492
fetchFn?: FetchLike;
14371493
} = {}
14381494
): Promise<OAuthTokens> {
1439-
const scope = provider.clientMetadata.scope;
1495+
// Prefer scope from options, fallback to provider.clientMetadata.scope
1496+
const effectiveScope = scope ?? provider.clientMetadata.scope;
14401497

14411498
// Use provider's prepareTokenRequest if available, otherwise fall back to authorization_code
14421499
let tokenRequestParams: URLSearchParams | undefined;
14431500
if (provider.prepareTokenRequest) {
1444-
tokenRequestParams = await provider.prepareTokenRequest(scope);
1501+
tokenRequestParams = await provider.prepareTokenRequest(effectiveScope);
14451502
}
14461503

14471504
// Default to authorization_code grant if no custom prepareTokenRequest

0 commit comments

Comments
 (0)