From 1b1eda617d6e436c4c5556a510544fa3c9fca9b6 Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Thu, 26 Feb 2026 11:41:31 +0530 Subject: [PATCH 1/3] Implement SEP-990 Enterprise Managed OAuth --- docs/client.md | 55 +++ packages/client/src/client/auth.ts | 61 ++- packages/client/src/client/authExtensions.ts | 261 ++++++++++++- packages/client/src/client/crossAppAccess.ts | 298 +++++++++++++++ packages/client/src/index.ts | 1 + .../client/test/client/authExtensions.test.ts | 301 ++++++++++++++- .../client/test/client/crossAppAccess.test.ts | 347 ++++++++++++++++++ 7 files changed, 1320 insertions(+), 4 deletions(-) create mode 100644 packages/client/src/client/crossAppAccess.ts create mode 100644 packages/client/test/client/crossAppAccess.test.ts diff --git a/docs/client.md b/docs/client.md index ea359f67e..641f7b82a 100644 --- a/docs/client.md +++ b/docs/client.md @@ -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). diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index cfba29d85..ced10d83a 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -184,6 +184,50 @@ export interface OAuthClientProvider { */ prepareTokenRequest?(scope?: string): URLSearchParams | Promise | 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; + + /** + * 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; + + /** + * 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; + + /** + * 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; + /** * Saves the OAuth discovery state after RFC 9728 and authorization server metadata * discovery. Providers can persist this state to avoid redundant discovery requests @@ -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) { @@ -550,6 +602,7 @@ async function authInternal( metadata, resource, authorizationCode, + scope, fetchFn }); @@ -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 { - 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 diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index c366947be..8efce69bc 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -5,7 +5,7 @@ * for common machine-to-machine authentication scenarios. */ -import type { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core'; +import type { FetchLike, OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core'; import type { CryptoKey, JWK } from 'jose'; import type { AddClientAuthentication, OAuthClientProvider } from './auth.js'; @@ -408,3 +408,262 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { return params; } } + +/** + * Context provided to the assertion callback in {@linkcode CrossAppAccessProvider}. + * Contains orchestrator-discovered information needed for JWT Authorization Grant requests. + */ +export interface CrossAppAccessContext { + /** + * The authorization server URL of the target MCP server. + * Discovered via RFC 9728 protected resource metadata. + */ + authorizationServerUrl: string; + + /** + * The resource URL of the target MCP server. + * Discovered via RFC 9728 protected resource metadata. + */ + resourceUrl: string; + + /** + * Optional scope being requested for the MCP server. + */ + scope?: string; + + /** + * Fetch function to use for HTTP requests (e.g., for IdP token exchange). + */ + fetchFn: FetchLike; +} + +/** + * Callback function type that provides a JWT Authorization Grant (ID-JAG). + * + * The callback receives context about the target MCP server (authorization server URL, + * resource URL, scope) and should return a JWT Authorization Grant that will be used + * to obtain an access token from the MCP server. + */ +export type AssertionCallback = (context: CrossAppAccessContext) => string | Promise; + +/** + * Options for creating a {@linkcode CrossAppAccessProvider}. + */ +export interface CrossAppAccessProviderOptions { + /** + * Callback function that provides a JWT Authorization Grant (ID-JAG). + * + * The callback receives the MCP server's authorization server URL, resource URL, + * and requested scope, and should return a JWT Authorization Grant obtained from + * the enterprise IdP via RFC 8693 token exchange. + * + * You can use the {@linkcode discoverAndRequestJwtAuthGrant} utility from + * `crossAppAccess.ts` for standard flows, or implement custom logic. + * + * @example + * ```ts + * assertion: async (ctx) => { + * 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; + * } + * ``` + */ + assertion: AssertionCallback; + + /** + * The `client_id` registered with the MCP server's authorization server. + */ + clientId: string; + + /** + * The `client_secret` for authenticating with the MCP server's authorization server. + */ + clientSecret: string; + + /** + * Optional client name for metadata. + */ + clientName?: string; + + /** + * Custom fetch implementation. Defaults to global fetch. + */ + fetchFn?: FetchLike; +} + +/** + * OAuth provider for Cross-App Access (Enterprise Managed Authorization) using JWT Authorization Grant. + * + * This provider implements the Enterprise Managed Authorization flow (SEP-990) where: + * 1. User authenticates with an enterprise IdP and the client obtains an ID Token + * 2. Client exchanges the ID Token for a JWT Authorization Grant (ID-JAG) via RFC 8693 token exchange + * 3. Client uses the JAG to obtain an access token from the MCP server via RFC 7523 JWT bearer grant + * + * The provider handles steps 2-3 automatically, with the JAG acquisition delegated to + * a callback function that you provide. This allows flexibility in how you obtain and + * cache ID Tokens from the IdP. + * + * @see https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx + * + * @example + * ```ts + * const provider = new CrossAppAccessProvider({ + * assertion: async (ctx) => { + * const result = await discoverAndRequestJwtAuthGrant({ + * idpUrl: 'https://idp.example.com', + * audience: ctx.authorizationServerUrl, + * resource: ctx.resourceUrl, + * idToken: await getIdToken(), // Your function to get ID token + * 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(serverUrl, { + * authProvider: provider + * }); + * ``` + */ +export class CrossAppAccessProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _clientInfo: OAuthClientInformation; + private _clientMetadata: OAuthClientMetadata; + private _assertionCallback: AssertionCallback; + private _fetchFn: FetchLike; + private _authorizationServerUrl?: string; + private _resourceUrl?: string; + private _scope?: string; + + constructor(options: CrossAppAccessProviderOptions) { + this._clientInfo = { + client_id: options.clientId, + client_secret: options.clientSecret + }; + this._clientMetadata = { + client_name: options.clientName ?? 'cross-app-access-client', + redirect_uris: [], + grant_types: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], + token_endpoint_auth_method: 'client_secret_basic' + }; + this._assertionCallback = options.assertion; + this._fetchFn = options.fetchFn ?? fetch; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for jwt-bearer flow'); + } + + saveCodeVerifier(): void { + // Not used for jwt-bearer + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for jwt-bearer flow'); + } + + /** + * Saves the authorization server URL discovered during OAuth flow. + * This is called by the auth() function after RFC 9728 discovery. + */ + saveAuthorizationServerUrl?(authorizationServerUrl: string): void { + this._authorizationServerUrl = authorizationServerUrl; + } + + /** + * Returns the cached authorization server URL if available. + */ + authorizationServerUrl?(): string | undefined { + return this._authorizationServerUrl; + } + + /** + * Saves the resource URL discovered during OAuth flow. + * This is called by the auth() function after RFC 9728 discovery. + */ + saveResourceUrl?(resourceUrl: string): void { + this._resourceUrl = resourceUrl; + } + + /** + * Returns the cached resource URL if available. + */ + resourceUrl?(): string | undefined { + return this._resourceUrl; + } + + async prepareTokenRequest(scope?: string): Promise { + // Get the authorization server URL and resource URL from cached state + const authServerUrl = this._authorizationServerUrl; + const resourceUrl = this._resourceUrl; + + if (!authServerUrl) { + throw new Error('Authorization server URL not available. Ensure auth() has been called first.'); + } + + if (!resourceUrl) { + throw new Error('Resource URL not available. Ensure auth() has been called first.'); + } + + // Store scope for assertion callback + this._scope = scope; + + // Call the assertion callback to get the JWT Authorization Grant + const jwtAuthGrant = await this._assertionCallback({ + authorizationServerUrl: authServerUrl, + resourceUrl: resourceUrl, + scope: this._scope, + fetchFn: this._fetchFn + }); + + // Return params for JWT bearer grant per RFC 7523 + const params = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwtAuthGrant + }); + + if (scope) { + params.set('scope', scope); + } + + return params; + } +} diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts new file mode 100644 index 000000000..e47acf9c3 --- /dev/null +++ b/packages/client/src/client/crossAppAccess.ts @@ -0,0 +1,298 @@ +/** + * Cross-App Access (Enterprise Managed Authorization) Layer 2 utilities. + * + * Provides standalone functions for RFC 8693 Token Exchange and RFC 7523 JWT Authorization Grant + * flows as specified in the Enterprise Managed Authorization specification (SEP-990). + * + * @see https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx + * @module + */ + +import type { FetchLike } from '@modelcontextprotocol/core'; +import { OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core'; + +import { discoverAuthorizationServerMetadata } from './auth.js'; + +/** + * Options for requesting a JWT Authorization Grant via RFC 8693 Token Exchange. + */ +export interface RequestJwtAuthGrantOptions { + /** + * The IdP's token endpoint URL where the token exchange request will be sent. + */ + tokenEndpoint: string | URL; + + /** + * The authorization server URL of the target MCP server (used as `audience` in the token exchange request). + */ + audience: string | URL; + + /** + * The resource identifier of the target MCP server (RFC 9728). + */ + resource: string | URL; + + /** + * The identity assertion (ID Token) from the enterprise IdP. + * This should be the OpenID Connect ID Token obtained during user authentication. + */ + idToken: string; + + /** + * The client ID registered with the IdP for token exchange. + */ + clientId: string; + + /** + * The client secret for authenticating with the IdP. + */ + clientSecret: string; + + /** + * Optional space-separated list of scopes to request for the target MCP server. + */ + scope?: string; + + /** + * Custom fetch implementation. Defaults to global fetch. + */ + fetchFn?: FetchLike; +} + +/** + * Options for discovering the IdP's token endpoint and requesting a JWT Authorization Grant. + * Extends {@linkcode RequestJwtAuthGrantOptions} with IdP discovery. + */ +export interface DiscoverAndRequestJwtAuthGrantOptions extends Omit { + /** + * The IdP's issuer URL for OAuth metadata discovery. + * Will be used to discover the token endpoint via `.well-known/oauth-authorization-server`. + */ + idpUrl: string | URL; +} + +/** + * Result from a successful JWT Authorization Grant token exchange. + */ +export interface JwtAuthGrantResult { + /** + * The JWT Authorization Grant (ID-JAG) that can be used to request an access token from the MCP server. + */ + jwtAuthGrant: string; + + /** + * Optional expiration time in seconds for the JWT Authorization Grant. + */ + expiresIn?: number; + + /** + * Optional scope granted by the IdP (may differ from requested scope). + */ + scope?: string; +} + +/** + * Requests a JWT Authorization Grant (ID-JAG) from an enterprise IdP using RFC 8693 Token Exchange. + * + * This function performs step 2 of the Enterprise Managed Authorization flow: + * exchanges an ID Token for a JWT Authorization Grant that can be used with the target MCP server. + * + * @param options - Configuration for the token exchange request + * @returns The JWT Authorization Grant and related metadata + * @throws {Error} If the token exchange fails or returns an error response + * + * @example + * ```ts + * const result = await requestJwtAuthorizationGrant({ + * tokenEndpoint: 'https://idp.example.com/token', + * audience: 'https://auth.chat.example/', + * resource: 'https://mcp.chat.example/', + * idToken: 'eyJhbGciOiJS...', + * clientId: 'my-idp-client', + * clientSecret: 'my-idp-secret', + * scope: 'chat.read chat.history' + * }); + * + * // Use result.jwtAuthGrant with the MCP server's authorization server + * ``` + */ +export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantOptions): Promise { + const { tokenEndpoint, audience, resource, idToken, clientId, clientSecret, scope, fetchFn = fetch } = options; + + // Prepare token exchange request per RFC 8693 + const params = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + audience: String(audience), + resource: String(resource), + subject_token: idToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + client_id: clientId, + client_secret: clientSecret + }); + + if (scope) { + params.set('scope', scope); + } + + const response = await fetchFn(String(tokenEndpoint), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + + // Try to parse as OAuth error response + const parseResult = OAuthErrorResponseSchema.safeParse(errorBody); + if (parseResult.success) { + const { error, error_description } = parseResult.data; + throw new Error(`Token exchange failed: ${error}${error_description ? ` - ${error_description}` : ''}`); + } + + throw new Error(`Token exchange failed with status ${response.status}: ${JSON.stringify(errorBody)}`); + } + + const responseBody = (await response.json()) as { + issued_token_type?: string; + token_type?: string; + access_token?: string; + expires_in?: number; + scope?: string; + }; + + // Validate response structure + if (responseBody.issued_token_type !== 'urn:ietf:params:oauth:token-type:id-jag') { + throw new Error( + `Invalid issued_token_type: expected 'urn:ietf:params:oauth:token-type:id-jag', got '${responseBody.issued_token_type}'` + ); + } + + if (responseBody.token_type !== 'N_A') { + throw new Error(`Invalid token_type: expected 'N_A', got '${responseBody.token_type}'`); + } + + if (typeof responseBody.access_token !== 'string') { + throw new TypeError('Missing or invalid access_token in token exchange response'); + } + + return { + jwtAuthGrant: responseBody.access_token, // Per RFC 8693, the JAG is returned in access_token field + expiresIn: responseBody.expires_in, + scope: responseBody.scope + }; +} + +/** + * Discovers the IdP's token endpoint and requests a JWT Authorization Grant. + * + * This is a convenience wrapper around {@linkcode requestJwtAuthorizationGrant} that + * first performs OAuth metadata discovery to find the token endpoint. + * + * @param options - Configuration including IdP URL for discovery + * @returns The JWT Authorization Grant and related metadata + * @throws {Error} If discovery fails or the token exchange fails + * + * @example + * ```ts + * const result = await discoverAndRequestJwtAuthGrant({ + * idpUrl: 'https://idp.example.com', + * audience: 'https://auth.chat.example/', + * resource: 'https://mcp.chat.example/', + * idToken: await getIdToken(), + * clientId: 'my-idp-client', + * clientSecret: 'my-idp-secret' + * }); + * ``` + */ +export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise { + const { idpUrl, fetchFn = fetch, ...restOptions } = options; + + // Discover IdP's authorization server metadata + const metadata = await discoverAuthorizationServerMetadata(String(idpUrl), { fetchFn }); + + if (!metadata?.token_endpoint) { + throw new Error(`Failed to discover token endpoint for IdP: ${idpUrl}`); + } + + // Perform token exchange + return requestJwtAuthorizationGrant({ + ...restOptions, + tokenEndpoint: metadata.token_endpoint, + fetchFn + }); +} + +/** + * Exchanges a JWT Authorization Grant for an access token at the MCP server's authorization server. + * + * This function performs step 3 of the Enterprise Managed Authorization flow: + * uses the JWT Authorization Grant to obtain an access token from the MCP server. + * + * @param options - Configuration for the JWT grant exchange + * @returns OAuth tokens (access token, token type, etc.) + * @throws {Error} If the exchange fails or returns an error response + * + * @example + * ```ts + * const tokens = await exchangeJwtAuthGrant({ + * tokenEndpoint: 'https://auth.chat.example/token', + * jwtAuthGrant: 'eyJhbGci...', + * clientId: 'my-mcp-client', + * clientSecret: 'my-mcp-secret' + * }); + * + * // Use tokens.access_token to access the MCP server + * ``` + */ +export async function exchangeJwtAuthGrant(options: { + tokenEndpoint: string | URL; + jwtAuthGrant: string; + clientId: string; + clientSecret: string; + fetchFn?: FetchLike; +}): Promise<{ access_token: string; token_type: string; expires_in?: number; scope?: string }> { + const { tokenEndpoint, jwtAuthGrant, clientId, clientSecret, fetchFn = fetch } = options; + + // Prepare JWT bearer grant request per RFC 7523 + const params = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwtAuthGrant, + client_id: clientId, + client_secret: clientSecret + }); + + const response = await fetchFn(String(tokenEndpoint), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + + // Try to parse as OAuth error response + const parseResult = OAuthErrorResponseSchema.safeParse(errorBody); + if (parseResult.success) { + const { error, error_description } = parseResult.data; + throw new Error(`JWT grant exchange failed: ${error}${error_description ? ` - ${error_description}` : ''}`); + } + + throw new Error(`JWT grant exchange failed with status ${response.status}: ${JSON.stringify(errorBody)}`); + } + + const responseBody = await response.json(); + + // Validate response using core schema + const parseResult = OAuthTokensSchema.safeParse(responseBody); + if (!parseResult.success) { + throw new Error(`Invalid token response: ${parseResult.error.message}`); + } + + return parseResult.data; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 787cfd2f0..c37d9fe28 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,6 +1,7 @@ export * from './client/auth.js'; export * from './client/authExtensions.js'; export * from './client/client.js'; +export * from './client/crossAppAccess.js'; export * from './client/middleware.js'; export * from './client/sse.js'; export * from './client/stdio.js'; diff --git a/packages/client/test/client/authExtensions.test.ts b/packages/client/test/client/authExtensions.test.ts index 4e5f3d9b9..f6aaf756c 100644 --- a/packages/client/test/client/authExtensions.test.ts +++ b/packages/client/test/client/authExtensions.test.ts @@ -1,10 +1,11 @@ import { createMockOAuthFetch } from '@modelcontextprotocol/test-helpers'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { auth } from '../../src/client/auth.js'; import { ClientCredentialsProvider, createPrivateKeyJwtAuth, + CrossAppAccessProvider, PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from '../../src/client/authExtensions.js'; @@ -330,3 +331,301 @@ describe('createPrivateKeyJwtAuth', () => { ); }); }); + +describe('CrossAppAccessProvider', () => { + const RESOURCE_SERVER_URL = 'https://mcp.chat.example/'; + const AUTH_SERVER_URL = 'https://auth.chat.example'; + const IDP_URL = 'https://idp.example.com'; + + it('successfully authenticates using Cross-App Access flow', async () => { + let assertionCallbackInvoked = false; + let jwtGrantUsed = ''; + + const provider = new CrossAppAccessProvider({ + assertion: async ctx => { + assertionCallbackInvoked = true; + expect(ctx.authorizationServerUrl).toBe(AUTH_SERVER_URL); + expect(ctx.resourceUrl).toBe(RESOURCE_SERVER_URL); + expect(ctx.scope).toBeUndefined(); + expect(ctx.fetchFn).toBeDefined(); + return 'jwt-authorization-grant-token'; + }, + clientId: 'my-mcp-client', + clientSecret: 'my-mcp-secret', + clientName: 'xaa-test-client' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + + jwtGrantUsed = params.get('assertion') || ''; + expect(jwtGrantUsed).toBe('jwt-authorization-grant-token'); + + // Verify client authentication + const headers = new Headers(init?.headers); + const authHeader = headers.get('Authorization'); + expect(authHeader).toBeTruthy(); + + const expectedCredentials = Buffer.from('my-mcp-client:my-mcp-secret').toString('base64'); + expect(authHeader).toBe(`Basic ${expectedCredentials}`); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + expect(assertionCallbackInvoked).toBe(true); + expect(jwtGrantUsed).toBe('jwt-authorization-grant-token'); + + const tokens = provider.tokens(); + expect(tokens).toBeTruthy(); + expect(tokens?.access_token).toBe('test-access-token'); + }); + + it('passes scope to assertion callback', async () => { + let capturedScope: string | undefined; + + const provider = new CrossAppAccessProvider({ + assertion: async ctx => { + capturedScope = ctx.scope; + return 'jwt-grant'; + }, + clientId: 'client', + clientSecret: 'secret' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL + }); + + await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + scope: 'chat.read chat.history', + fetchFn: fetchMock + }); + + expect(capturedScope).toBe('chat.read chat.history'); + }); + + it('passes custom fetchFn to assertion callback', async () => { + let capturedFetchFn: unknown; + + const customFetch = vi.fn(fetch); + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL + }); + + // Wrap the mock to track calls + const wrappedFetch = vi.fn((...args: Parameters) => fetchMock(...args)); + + const provider = new CrossAppAccessProvider({ + assertion: async ctx => { + capturedFetchFn = ctx.fetchFn; + return 'jwt-grant'; + }, + clientId: 'client', + clientSecret: 'secret', + fetchFn: customFetch + }); + + await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: wrappedFetch + }); + + // The assertion callback should receive the custom fetch function + expect(capturedFetchFn).toBe(customFetch); + }); + + it('throws error when authorization server URL is not available', async () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + // Try to call prepareTokenRequest without going through auth() + await expect(provider.prepareTokenRequest()).rejects.toThrow( + 'Authorization server URL not available. Ensure auth() has been called first.' + ); + }); + + it('throws error when resource URL is not available', async () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + // Manually set authorization server URL but not resource URL + provider.saveAuthorizationServerUrl?.(AUTH_SERVER_URL); + + await expect(provider.prepareTokenRequest()).rejects.toThrow( + 'Resource URL not available. Ensure auth() has been called first.' + ); + }); + + it('stores and retrieves authorization server URL', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + expect(provider.authorizationServerUrl?.()).toBeUndefined(); + + provider.saveAuthorizationServerUrl?.(AUTH_SERVER_URL); + expect(provider.authorizationServerUrl?.()).toBe(AUTH_SERVER_URL); + }); + + it('stores and retrieves resource URL', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + expect(provider.resourceUrl?.()).toBeUndefined(); + + provider.saveResourceUrl?.(RESOURCE_SERVER_URL); + expect(provider.resourceUrl?.()).toBe(RESOURCE_SERVER_URL); + }); + + it('has correct client metadata', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret', + clientName: 'custom-xaa-client' + }); + + const metadata = provider.clientMetadata; + expect(metadata.client_name).toBe('custom-xaa-client'); + expect(metadata.redirect_uris).toEqual([]); + expect(metadata.grant_types).toEqual(['urn:ietf:params:oauth:grant-type:jwt-bearer']); + expect(metadata.token_endpoint_auth_method).toBe('client_secret_basic'); + }); + + it('uses default client name when not provided', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + expect(provider.clientMetadata.client_name).toBe('cross-app-access-client'); + }); + + it('returns undefined for redirectUrl (non-interactive flow)', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + expect(provider.redirectUrl).toBeUndefined(); + }); + + it('throws error for redirectToAuthorization (not used in jwt-bearer)', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + expect(() => provider.redirectToAuthorization()).toThrow('redirectToAuthorization is not used for jwt-bearer flow'); + }); + + it('throws error for codeVerifier (not used in jwt-bearer)', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + expect(() => provider.codeVerifier()).toThrow('codeVerifier is not used for jwt-bearer flow'); + }); + + it('handles assertion callback errors gracefully', async () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => { + throw new Error('Failed to get ID token from IdP'); + }, + clientId: 'client', + clientSecret: 'secret' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL + }); + + await expect( + auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }) + ).rejects.toThrow('Failed to get ID token from IdP'); + }); + + it('allows assertion callback to return a promise', async () => { + const provider = new CrossAppAccessProvider({ + assertion: ctx => { + return new Promise(resolve => { + setTimeout(() => resolve('async-jwt-grant'), 10); + }); + }, + clientId: 'client', + clientSecret: 'secret' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params.get('assertion')).toBe('async-jwt-grant'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); + + it('includes scope in token request params when provided', async () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params.get('scope')).toBe('chat.read chat.write'); + } + }); + + await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + scope: 'chat.read chat.write', + fetchFn: fetchMock + }); + }); +}); diff --git a/packages/client/test/client/crossAppAccess.test.ts b/packages/client/test/client/crossAppAccess.test.ts new file mode 100644 index 000000000..bfa3b5a15 --- /dev/null +++ b/packages/client/test/client/crossAppAccess.test.ts @@ -0,0 +1,347 @@ +import type { FetchLike } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { + discoverAndRequestJwtAuthGrant, + exchangeJwtAuthGrant, + requestJwtAuthorizationGrant +} from '../../src/client/crossAppAccess.js'; + +describe('crossAppAccess', () => { + describe('requestJwtAuthorizationGrant', () => { + it('successfully exchanges ID token for JWT Authorization Grant', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + token_type: 'N_A', + expires_in: 300, + scope: 'chat.read chat.history' + }) + } as Response); + + const result = await requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...', + clientId: 'my-idp-client', + clientSecret: 'my-idp-secret', + scope: 'chat.read chat.history', + fetchFn: mockFetch + }); + + expect(result.jwtAuthGrant).toBe('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'); + expect(result.expiresIn).toBe(300); + expect(result.scope).toBe('chat.read chat.history'); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, init] = mockFetch.mock.calls[0]!; + expect(url).toBe('https://idp.example.com/token'); + expect(init?.method).toBe('POST'); + expect(init?.headers).toEqual({ + 'Content-Type': 'application/x-www-form-urlencoded' + }); + + const body = new URLSearchParams(init?.body as string); + expect(body.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:token-exchange'); + expect(body.get('requested_token_type')).toBe('urn:ietf:params:oauth:token-type:id-jag'); + expect(body.get('audience')).toBe('https://auth.chat.example/'); + expect(body.get('resource')).toBe('https://mcp.chat.example/'); + expect(body.get('subject_token')).toBe('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...'); + expect(body.get('subject_token_type')).toBe('urn:ietf:params:oauth:token-type:id_token'); + expect(body.get('client_id')).toBe('my-idp-client'); + expect(body.get('client_secret')).toBe('my-idp-secret'); + expect(body.get('scope')).toBe('chat.read chat.history'); + }); + + it('works without optional scope parameter', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'jag-token', + token_type: 'N_A' + }) + } as Response); + + const result = await requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }); + + expect(result.jwtAuthGrant).toBe('jag-token'); + + const body = new URLSearchParams(mockFetch.mock.calls[0]![1]?.body as string); + expect(body.get('scope')).toBeNull(); + }); + + it('throws error when issued_token_type is incorrect', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + access_token: 'token', + token_type: 'N_A' + }) + } as Response); + + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow("Invalid issued_token_type: expected 'urn:ietf:params:oauth:token-type:id-jag'"); + }); + + it('throws error when token_type is incorrect', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'token', + token_type: 'Bearer' + }) + } as Response); + + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow("Invalid token_type: expected 'N_A'"); + }); + + it('throws error when access_token is missing', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }) + } as Response); + + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow('Missing or invalid access_token in token exchange response'); + }); + + it('handles OAuth error responses', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + error: 'invalid_grant', + error_description: 'Audience validation failed' + }) + } as Response); + + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow('Token exchange failed: invalid_grant - Audience validation failed'); + }); + + it('handles non-OAuth error responses', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ message: 'Internal server error' }) + } as Response); + + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow('Token exchange failed with status 500'); + }); + }); + + describe('discoverAndRequestJwtAuthGrant', () => { + it('discovers token endpoint and performs token exchange', async () => { + const mockFetch = vi.fn(); + + // Mock discovery response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issuer: 'https://idp.example.com', + authorization_endpoint: 'https://idp.example.com/authorize', + token_endpoint: 'https://idp.example.com/token', + jwks_uri: 'https://idp.example.com/jwks', + response_types_supported: ['code'], + grant_types_supported: ['urn:ietf:params:oauth:grant-type:token-exchange'] + }) + } as Response); + + // Mock token exchange response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'jag-token', + token_type: 'N_A', + expires_in: 300 + }) + } as Response); + + const result = await discoverAndRequestJwtAuthGrant({ + idpUrl: 'https://idp.example.com', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }); + + expect(result.jwtAuthGrant).toBe('jag-token'); + expect(result.expiresIn).toBe(300); + + expect(mockFetch).toHaveBeenCalledTimes(2); + // First call is discovery + expect(String(mockFetch.mock.calls[0]![0])).toContain('.well-known/oauth-authorization-server'); + // Second call is token exchange + expect(String(mockFetch.mock.calls[1]![0])).toBe('https://idp.example.com/token'); + }); + + it('throws error when token endpoint is not discovered', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issuer: 'https://idp.example.com', + authorization_endpoint: 'https://idp.example.com/authorize' + // Missing token_endpoint and response_types_supported + }) + } as Response); + + await expect( + discoverAndRequestJwtAuthGrant({ + idpUrl: 'https://idp.example.com', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow(); // Zod validation error + }); + }); + + describe('exchangeJwtAuthGrant', () => { + it('successfully exchanges JWT Authorization Grant for access token', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'mcp-access-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'chat.read chat.history' + }) + } as Response); + + const result = await exchangeJwtAuthGrant({ + tokenEndpoint: 'https://auth.chat.example/token', + jwtAuthGrant: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + clientId: 'my-mcp-client', + clientSecret: 'my-mcp-secret', + fetchFn: mockFetch + }); + + expect(result.access_token).toBe('mcp-access-token'); + expect(result.token_type).toBe('Bearer'); + expect(result.expires_in).toBe(3600); + expect(result.scope).toBe('chat.read chat.history'); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, init] = mockFetch.mock.calls[0]!; + expect(url).toBe('https://auth.chat.example/token'); + expect(init?.method).toBe('POST'); + + const body = new URLSearchParams(init?.body as string); + expect(body.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + expect(body.get('assertion')).toBe('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'); + expect(body.get('client_id')).toBe('my-mcp-client'); + expect(body.get('client_secret')).toBe('my-mcp-secret'); + }); + + it('handles OAuth error responses', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + error: 'invalid_grant', + error_description: 'JWT signature verification failed' + }) + } as Response); + + await expect( + exchangeJwtAuthGrant({ + tokenEndpoint: 'https://auth.chat.example/token', + jwtAuthGrant: 'invalid-jwt', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow('JWT grant exchange failed: invalid_grant - JWT signature verification failed'); + }); + + it('validates token response with schema', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + // Missing required fields + token_type: 'Bearer' + }) + } as Response); + + await expect( + exchangeJwtAuthGrant({ + tokenEndpoint: 'https://auth.chat.example/token', + jwtAuthGrant: 'jwt', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow('Invalid token response'); + }); + }); +}); From c3994e502f7aefee898bc5d75356477040ef466e Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Thu, 26 Feb 2026 11:48:18 +0530 Subject: [PATCH 2/3] Resolving prettier error --- packages/client/test/client/authExtensions.test.ts | 4 +--- packages/client/test/client/crossAppAccess.test.ts | 6 +----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/client/test/client/authExtensions.test.ts b/packages/client/test/client/authExtensions.test.ts index f6aaf756c..f71b3a016 100644 --- a/packages/client/test/client/authExtensions.test.ts +++ b/packages/client/test/client/authExtensions.test.ts @@ -470,9 +470,7 @@ describe('CrossAppAccessProvider', () => { // Manually set authorization server URL but not resource URL provider.saveAuthorizationServerUrl?.(AUTH_SERVER_URL); - await expect(provider.prepareTokenRequest()).rejects.toThrow( - 'Resource URL not available. Ensure auth() has been called first.' - ); + await expect(provider.prepareTokenRequest()).rejects.toThrow('Resource URL not available. Ensure auth() has been called first.'); }); it('stores and retrieves authorization server URL', () => { diff --git a/packages/client/test/client/crossAppAccess.test.ts b/packages/client/test/client/crossAppAccess.test.ts index bfa3b5a15..a36809f75 100644 --- a/packages/client/test/client/crossAppAccess.test.ts +++ b/packages/client/test/client/crossAppAccess.test.ts @@ -1,11 +1,7 @@ import type { FetchLike } from '@modelcontextprotocol/core'; import { describe, expect, it, vi } from 'vitest'; -import { - discoverAndRequestJwtAuthGrant, - exchangeJwtAuthGrant, - requestJwtAuthorizationGrant -} from '../../src/client/crossAppAccess.js'; +import { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from '../../src/client/crossAppAccess.js'; describe('crossAppAccess', () => { describe('requestJwtAuthorizationGrant', () => { From b3d31ff7c2e79f7c36289cc74e800ae9adebe72c Mon Sep 17 00:00:00 2001 From: Sagar Viswanatha Date: Thu, 26 Feb 2026 11:52:55 +0530 Subject: [PATCH 3/3] docs: Fix TypeDoc link resolution for crossAppAccess utilities --- packages/client/src/client/authExtensions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index 8efce69bc..d5432705a 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -457,8 +457,8 @@ export interface CrossAppAccessProviderOptions { * and requested scope, and should return a JWT Authorization Grant obtained from * the enterprise IdP via RFC 8693 token exchange. * - * You can use the {@linkcode discoverAndRequestJwtAuthGrant} utility from - * `crossAppAccess.ts` for standard flows, or implement custom logic. + * You can use the utility functions from the `crossAppAccess` module + * for standard flows, or implement custom logic. * * @example * ```ts