diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 2946f3f41..1dcf2e4a5 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -188,6 +188,14 @@ export interface OAuthClientProvider { * } */ prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; + + /** + * Saves the resource URL determined during the OAuth flow. + * + * Called by {@linkcode auth} after resource URL selection so providers can + * use it in grant-specific logic (e.g., Cross-App Access token exchange). + */ + saveResourceUrl?(url: URL): void | Promise; } export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; @@ -422,6 +430,10 @@ async function authInternal( const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + if (resource) { + await provider.saveResourceUrl?.(resource); + } + const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn }); diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index d5f63bd66..cc6500f86 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -5,11 +5,67 @@ * 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'; +/** + * Abstract base class for non-interactive (machine-to-machine) OAuthClientProvider + * implementations. Provides the common boilerplate shared by all M2M providers: + * token/client-info storage, no-op PKCE/redirect methods, and a simple + * `prepareTokenRequest` override point. + * + * Subclasses only need to set up `_clientInfo` / `_clientMetadata` in the + * constructor and optionally override `prepareTokenRequest`. + */ +export abstract class NonInteractiveOAuthProvider implements OAuthClientProvider { + protected _tokens?: OAuthTokens; + protected _clientInfo: OAuthClientInformation; + protected _clientMetadata: OAuthClientMetadata; + + constructor(clientInfo: OAuthClientInformation, clientMetadata: OAuthClientMetadata) { + this._clientInfo = clientInfo; + this._clientMetadata = clientMetadata; + } + + 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 non-interactive flows'); + } + + saveCodeVerifier(): void { + // Not used for non-interactive flows + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for non-interactive flows'); + } +} + /** * Helper to produce a private_key_jwt client authentication function. * @@ -123,58 +179,20 @@ export interface ClientCredentialsProviderOptions { * authProvider: provider * }); */ -export class ClientCredentialsProvider implements OAuthClientProvider { - private _tokens?: OAuthTokens; - private _clientInfo: OAuthClientInformation; - private _clientMetadata: OAuthClientMetadata; - +export class ClientCredentialsProvider extends NonInteractiveOAuthProvider { constructor(options: ClientCredentialsProviderOptions) { - this._clientInfo = { - client_id: options.clientId, - client_secret: options.clientSecret - }; - this._clientMetadata = { - client_name: options.clientName ?? 'client-credentials-client', - redirect_uris: [], - grant_types: ['client_credentials'], - token_endpoint_auth_method: 'client_secret_basic' - }; - } - - 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 client_credentials flow'); - } - - saveCodeVerifier(): void { - // Not used for client_credentials - } - - codeVerifier(): string { - throw new Error('codeVerifier is not used for client_credentials flow'); + super( + { + client_id: options.clientId, + client_secret: options.clientSecret + }, + { + client_name: options.clientName ?? 'client-credentials-client', + redirect_uris: [], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'client_secret_basic' + } + ); } prepareTokenRequest(scope?: string): URLSearchParams { @@ -232,22 +250,21 @@ export interface PrivateKeyJwtProviderOptions { * authProvider: provider * }); */ -export class PrivateKeyJwtProvider implements OAuthClientProvider { - private _tokens?: OAuthTokens; - private _clientInfo: OAuthClientInformation; - private _clientMetadata: OAuthClientMetadata; +export class PrivateKeyJwtProvider extends NonInteractiveOAuthProvider { addClientAuthentication: AddClientAuthentication; constructor(options: PrivateKeyJwtProviderOptions) { - this._clientInfo = { - client_id: options.clientId - }; - this._clientMetadata = { - client_name: options.clientName ?? 'private-key-jwt-client', - redirect_uris: [], - grant_types: ['client_credentials'], - token_endpoint_auth_method: 'private_key_jwt' - }; + super( + { + client_id: options.clientId + }, + { + client_name: options.clientName ?? 'private-key-jwt-client', + redirect_uris: [], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'private_key_jwt' + } + ); this.addClientAuthentication = createPrivateKeyJwtAuth({ issuer: options.clientId, subject: options.clientId, @@ -257,42 +274,6 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { }); } - 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 client_credentials flow'); - } - - saveCodeVerifier(): void { - // Not used for client_credentials - } - - codeVerifier(): string { - throw new Error('codeVerifier is not used for client_credentials flow'); - } - prepareTokenRequest(scope?: string): URLSearchParams { const params = new URLSearchParams({ grant_type: 'client_credentials' }); if (scope) params.set('scope', scope); @@ -330,22 +311,21 @@ export interface StaticPrivateKeyJwtProviderOptions { * signing a JWT on each request, it accepts a pre-built JWT assertion string and * uses it directly for authentication. */ -export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { - private _tokens?: OAuthTokens; - private _clientInfo: OAuthClientInformation; - private _clientMetadata: OAuthClientMetadata; +export class StaticPrivateKeyJwtProvider extends NonInteractiveOAuthProvider { addClientAuthentication: AddClientAuthentication; constructor(options: StaticPrivateKeyJwtProviderOptions) { - this._clientInfo = { - client_id: options.clientId - }; - this._clientMetadata = { - client_name: options.clientName ?? 'static-private-key-jwt-client', - redirect_uris: [], - grant_types: ['client_credentials'], - token_endpoint_auth_method: 'private_key_jwt' - }; + super( + { + client_id: options.clientId + }, + { + client_name: options.clientName ?? 'static-private-key-jwt-client', + redirect_uris: [], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'private_key_jwt' + } + ); const assertion = options.jwtBearerAssertion; this.addClientAuthentication = async (_headers, params) => { @@ -354,45 +334,149 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { }; } - get redirectUrl(): undefined { - return undefined; + prepareTokenRequest(scope?: string): URLSearchParams { + const params = new URLSearchParams({ grant_type: 'client_credentials' }); + if (scope) params.set('scope', scope); + return params; } +} - get clientMetadata(): OAuthClientMetadata { - return this._clientMetadata; - } +/** + * Context passed to the assertion callback in {@link CrossAppAccessProvider}. + */ +export interface CrossAppAccessAssertionContext { + /** The MCP authorization server URL (use as `audience` in token exchange). */ + authorizationServerUrl: string; + /** The MCP resource URL (use as `resource` in token exchange). */ + resourceUrl: string; + /** Scope requested by the orchestrator. */ + scope?: string; + /** Fetch function from the provider, if configured. */ + fetchFn?: FetchLike; +} - clientInformation(): OAuthClientInformation { - return this._clientInfo; - } +/** + * Options for creating a CrossAppAccessProvider. + */ +export interface CrossAppAccessProviderOptions { + /** + * Returns the JWT Authorization Grant (JAG) assertion. + * Called each time tokens need to be obtained (initial auth and 401 retry). + * + * Use {@link requestJwtAuthorizationGrant} from `crossAppAccess.ts` for the + * standard RFC 8693 token exchange flow, or implement custom logic. + */ + assertion: (context: CrossAppAccessAssertionContext) => Promise; - saveClientInformation(info: OAuthClientInformation): void { - this._clientInfo = info; - } + /** MCP client ID for authentication with the MCP authorization server. */ + clientId: string; - tokens(): OAuthTokens | undefined { - return this._tokens; - } + /** MCP client secret. */ + clientSecret?: string; - saveTokens(tokens: OAuthTokens): void { - this._tokens = tokens; + /** Optional client name for metadata. */ + clientName?: string; + + /** Optional scopes to request. */ + scope?: string[]; + + /** Optional fetch function passed through to the assertion callback. */ + fetchFn?: FetchLike; +} + +/** + * OAuth provider for Cross-App Access using the Identity Assertion Authorization Grant. + * + * Implements a two-step OAuth flow: + * 1. Obtains a JWT Authorization Grant (JAG) via the `assertion` callback + * (typically RFC 8693 Token Exchange with an IDP) + * 2. Exchanges the JAG for an access token at the MCP authorization server + * via RFC 7523 JWT Bearer grant (handled by `withOAuth` infrastructure) + * + * Step 1 is delegated to the caller via the `assertion` callback. Step 2 is + * executed by the SDK's standard token request machinery, providing token + * caching, 401 retry, and refresh handling automatically. + * + * @example + * ```typescript + * import { CrossAppAccessProvider, requestJwtAuthorizationGrant } from '@modelcontextprotocol/client'; + * + * const provider = new CrossAppAccessProvider({ + * assertion: async (ctx) => requestJwtAuthorizationGrant({ + * tokenEndpoint: 'https://idp.example.com/token', + * audience: ctx.authorizationServerUrl, + * resource: ctx.resourceUrl, + * idToken: await getIdToken(), + * clientId: 'my-idp-client', + * clientSecret: 'my-idp-secret', + * scope: ctx.scope, + * fetchFn: ctx.fetchFn + * }), + * clientId: 'my-mcp-client', + * clientSecret: 'my-mcp-secret', + * scope: ['read', 'write'] + * }); + * + * const transport = new StreamableHTTPClientTransport(serverUrl, { + * authProvider: provider + * }); + * ``` + */ +export class CrossAppAccessProvider extends NonInteractiveOAuthProvider { + private _options: CrossAppAccessProviderOptions; + private _authServerUrl?: string | URL; + private _resourceUrl?: URL; + + constructor(options: CrossAppAccessProviderOptions) { + super( + { + client_id: options.clientId, + client_secret: options.clientSecret + }, + { + client_name: options.clientName ?? 'cross-app-access-client', + redirect_uris: [], + grant_types: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], + token_endpoint_auth_method: options.clientSecret ? 'client_secret_basic' : 'none', + scope: options.scope?.join(' ') + } + ); + this._options = options; } - redirectToAuthorization(): void { - throw new Error('redirectToAuthorization is not used for client_credentials flow'); + saveAuthorizationServerUrl(url: string | URL): void { + this._authServerUrl = url; } - saveCodeVerifier(): void { - // Not used for client_credentials + authorizationServerUrl(): string | URL | undefined { + return this._authServerUrl; } - codeVerifier(): string { - throw new Error('codeVerifier is not used for client_credentials flow'); + saveResourceUrl(url: URL): void { + this._resourceUrl = url; } - prepareTokenRequest(scope?: string): URLSearchParams { - const params = new URLSearchParams({ grant_type: 'client_credentials' }); - if (scope) params.set('scope', scope); + /** + * Calls the assertion callback to get a JAG, then returns JWT Bearer + * grant params for the MCP AS token request. + */ + async prepareTokenRequest(scope?: string): Promise { + const effectiveScope = scope ?? this._options.scope?.join(' '); + + const assertion = await this._options.assertion({ + authorizationServerUrl: String(this._authServerUrl ?? ''), + resourceUrl: this._resourceUrl?.href ?? '', + scope: effectiveScope, + fetchFn: this._options.fetchFn + }); + + const params = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion + }); + if (effectiveScope) { + params.set('scope', effectiveScope); + } return params; } } diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts new file mode 100644 index 000000000..9fcc90e5b --- /dev/null +++ b/packages/client/src/client/crossAppAccess.ts @@ -0,0 +1,106 @@ +/** + * Cross-App Access utilities for the Identity Assertion Authorization Grant flow. + * + * Provides standalone functions for RFC 8693 Token Exchange (ID token → JAG). + * Used by {@link CrossAppAccessProvider} and available for direct use. + */ + +import type { FetchLike } from '@modelcontextprotocol/core'; +import { JagTokenExchangeResponseSchema } from '@modelcontextprotocol/core'; + +import { discoverAuthorizationServerMetadata, parseErrorResponse } from './auth.js'; + +/** + * Options for requesting a JWT Authorization Grant from an Identity Provider. + */ +export interface RequestJwtAuthGrantOptions { + /** The IDP's token endpoint URL. */ + tokenEndpoint: string; + /** The MCP authorization server URL (used as the `audience` parameter). */ + audience: string; + /** The MCP resource server URL (used as the `resource` parameter). */ + resource: string; + /** The OIDC ID token to exchange. */ + idToken: string; + /** Client ID for authentication with the IDP. */ + clientId: string; + /** Client secret for authentication with the IDP. */ + clientSecret?: string; + /** Optional scopes to request. */ + scope?: string; + /** Optional fetch function for HTTP requests. */ + fetchFn?: FetchLike; +} + +/** + * Requests a JWT Authorization Grant (JAG) from an Identity Provider via + * RFC 8693 Token Exchange. Returns the JAG to be used as a JWT Bearer + * assertion (RFC 7523) against the MCP authorization server. + */ +export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantOptions): Promise { + const effectiveFetch = options.fetchFn ?? fetch; + + const body = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + subject_token: options.idToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + audience: options.audience, + resource: options.resource, + client_id: options.clientId, + ...(options.clientSecret ? { client_secret: options.clientSecret } : {}), + ...(options.scope ? { scope: options.scope } : {}) + }); + + const response = await effectiveFetch(options.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + }, + body + }); + + if (!response.ok) { + throw await parseErrorResponse(response); + } + + const data = JagTokenExchangeResponseSchema.parse(await response.json()); + return data.access_token; +} + +/** + * Options for discovering and requesting a JWT Authorization Grant. + * Extends {@link RequestJwtAuthGrantOptions} but replaces `tokenEndpoint` + * with `idpUrl` / `idpTokenEndpoint` for automatic discovery. + */ +export interface DiscoverAndRequestJwtAuthGrantOptions extends Omit { + /** Identity Provider's base URL for OAuth/OIDC discovery. */ + idpUrl: string; + /** IDP token endpoint URL. When provided, skips IDP metadata discovery. */ + idpTokenEndpoint?: string; +} + +/** + * Discovers the IDP's token endpoint via metadata, then requests a JAG. + * Convenience wrapper over {@link requestJwtAuthorizationGrant}. + */ +export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise { + const { idpUrl, idpTokenEndpoint, ...rest } = options; + + let tokenEndpoint = idpTokenEndpoint; + + if (!tokenEndpoint) { + const idpMetadata = await discoverAuthorizationServerMetadata(idpUrl, { fetchFn: options.fetchFn }); + tokenEndpoint = idpMetadata?.token_endpoint; + + if (!tokenEndpoint) { + throw new Error(`IDP metadata discovery for ${idpUrl} did not return a token_endpoint`); + } + } + + return requestJwtAuthorizationGrant({ + ...rest, + tokenEndpoint + }); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 787cfd2f0..ac9d5d25f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,5 +1,6 @@ export * from './client/auth.js'; export * from './client/authExtensions.js'; +export * from './client/crossAppAccess.js'; export * from './client/client.js'; export * from './client/middleware.js'; export * from './client/sse.js'; diff --git a/packages/core/src/shared/auth.ts b/packages/core/src/shared/auth.ts index f1268f3bc..70537c469 100644 --- a/packages/core/src/shared/auth.ts +++ b/packages/core/src/shared/auth.ts @@ -138,6 +138,26 @@ export const OAuthTokensSchema = z }) .strip(); +/** + * RFC 8693 Token Exchange response for the JAG (JWT Authorization Grant) flow. + * + * Validates the three required fields: + * - `access_token`: the issued JAG + * - `issued_token_type`: must be `urn:ietf:params:oauth:token-type:id-jag` + * - `token_type`: must be `N_A` (case-insensitive per RFC 8693 §2.2.1) + */ +export const JagTokenExchangeResponseSchema = z + .object({ + access_token: z.string(), + issued_token_type: z.literal('urn:ietf:params:oauth:token-type:id-jag'), + token_type: z + .string() + .refine((v) => v.toLowerCase() === 'n_a', { + message: "Expected token_type 'N_A'" + }) + }) + .strip(); + /** * OAuth 2.1 error response */ @@ -219,6 +239,7 @@ export type OpenIdProviderMetadata = z.infer; export type OAuthTokens = z.infer; +export type JagTokenExchangeResponse = z.infer; export type OAuthErrorResponse = z.infer; export type OAuthClientMetadata = z.infer; export type OAuthClientInformation = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2d18d927..111ae72c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ catalogs: jose: specifier: ^6.1.1 version: 6.1.3 + qs: + specifier: ^6.13.0 + version: 6.14.0 runtimeServerOnly: '@hono/node-server': specifier: ^1.19.8 @@ -466,6 +469,9 @@ importers: pkce-challenge: specifier: catalog:runtimeShared version: 5.0.1 + qs: + specifier: catalog:runtimeClientOnly + version: 6.14.0 zod: specifier: catalog:runtimeShared version: 4.3.5 @@ -500,6 +506,9 @@ importers: '@types/eventsource': specifier: catalog:devTools version: 1.1.15 + '@types/qs': + specifier: ^6.9.18 + version: 6.14.0 '@typescript/native-preview': specifier: catalog:devTools version: 7.0.0-dev.20260105.1 @@ -4630,6 +4639,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -9701,6 +9714,10 @@ snapshots: punycode@2.3.1: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + qs@6.14.1: dependencies: side-channel: 1.1.0 diff --git a/src/conformance/everything-client.ts b/src/conformance/everything-client.ts index d21c8e66a..3803eb57a 100644 --- a/src/conformance/everything-client.ts +++ b/src/conformance/everything-client.ts @@ -17,7 +17,10 @@ import { StreamableHTTPClientTransport, ElicitRequestSchema, ClientCredentialsProvider, - PrivateKeyJwtProvider + CrossAppAccessProvider, + PrivateKeyJwtProvider, + discoverOAuthProtectedResourceMetadata, + requestJwtAuthorizationGrant } from '@modelcontextprotocol/client'; import { z } from 'zod'; import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry.js'; @@ -47,6 +50,15 @@ const ClientConformanceContextSchema = z.discriminatedUnion('name', [ name: z.literal('auth/client-credentials-basic'), client_id: z.string(), client_secret: z.string() + }), + z.object({ + name: z.literal('auth/cross-app-access-complete-flow'), + client_id: z.string(), + client_secret: z.string(), + idp_client_id: z.string(), + idp_id_token: z.string(), + idp_issuer: z.string(), + idp_token_endpoint: z.string() }) ]); @@ -245,6 +257,54 @@ async function runClientCredentialsBasic(serverUrl: string): Promise { registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); +// ============================================================================ +// Cross-App Access (SEP-990) scenario +// ============================================================================ + +async function runCrossAppAccess(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/cross-app-access-complete-flow') { + throw new Error(`Expected cross-app-access context, got ${ctx.name}`); + } + + // Discover the MCP authorization server URL via RFC 9728 + const prm = await discoverOAuthProtectedResourceMetadata(serverUrl); + const mcpAuthServerUrl = prm.authorization_servers?.[0] ?? serverUrl; + + const provider = new CrossAppAccessProvider({ + assertion: async (actx) => requestJwtAuthorizationGrant({ + tokenEndpoint: ctx.idp_token_endpoint, + audience: actx.authorizationServerUrl, + resource: actx.resourceUrl, + idToken: ctx.idp_id_token, + clientId: ctx.idp_client_id, + scope: actx.scope, + fetchFn: actx.fetchFn + }), + clientId: ctx.client_id, + clientSecret: ctx.client_secret + }); + + // Pre-set the auth server URL so the provider knows it before prepareTokenRequest + provider.saveAuthorizationServerUrl(mcpAuthServerUrl); + + const client = new Client({ name: 'conformance-cross-app-access', version: '1.0.0' }, { capabilities: {} }); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Connected via Cross-App Access'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/cross-app-access-complete-flow', runCrossAppAccess); + // ============================================================================ // Elicitation defaults scenario // ============================================================================