From 5e641f66ab888c5af115c9f215ddfc327f89c65c Mon Sep 17 00:00:00 2001 From: Andor Markus Date: Wed, 16 Jul 2025 11:55:04 +0200 Subject: [PATCH 1/6] feat: add multi-level initial access token support for OAuth 2.0 Dynamic Client Registration (RFC 7591) - Extend OAuthClientProvider interface with optional initialAccessToken() method - Update registerClient() to support multi-level fallback: 1. Explicit parameter (highest priority) 2. Provider method 3. OAUTH_INITIAL_ACCESS_TOKEN environment variable 4. None (existing behavior) - Add initialAccessToken option to StreamableHTTPClientTransport and SSEClientTransport - Update auth flow to pass initial access token through all transport layers - Add Authorization: Bearer header to registration requests when token available - Add comprehensive test coverage for all fallback levels - Maintain backward compatibility with servers not requiring pre-authorization Implements RFC 7591 specification for OAuth 2.0 Dynamic Client Registration with initial access tokens for authorization servers requiring pre-authorization. --- .idea/.gitignore | 8 ++ .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 ++ .idea/typescript-sdk.iml | 8 ++ .idea/vcs.xml | 6 + package-lock.json | 4 +- src/client/auth.test.ts | 134 ++++++++++++++++++ src/client/auth.ts | 63 +++++++- src/client/sse.test.ts | 75 ++++++++++ src/client/sse.ts | 18 ++- src/client/streamableHttp.test.ts | 69 +++++++++ src/client/streamableHttp.ts | 16 ++- 13 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/typescript-sdk.iml create mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..13566b81b --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 000000000..105ce2da2 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..44664d9f6 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..f6e4d6a29 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/typescript-sdk.iml b/.idea/typescript-sdk.iml new file mode 100644 index 000000000..67f8478ce --- /dev/null +++ b/.idea/typescript-sdk.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 01bc09539..fa1bde0eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.15.0", + "version": "1.15.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.15.0", + "version": "1.15.1", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index ce0cc7081..eb26abc45 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1158,6 +1158,140 @@ describe("OAuth Authorization", () => { }) ).rejects.toThrow("Dynamic client registration failed"); }); + + describe("initial access token support", () => { + it("includes initial access token from explicit parameter", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validClientInfo, + }); + + await registerClient("https://auth.example.com", { + clientMetadata: validClientMetadata, + initialAccessToken: "explicit-token", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/register", + }), + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer explicit-token", + }, + body: JSON.stringify(validClientMetadata), + }) + ); + }); + + it("includes initial access token from provider method", async () => { + const mockProvider: OAuthClientProvider = { + get redirectUrl() { return "http://localhost:3000/callback"; }, + get clientMetadata() { return validClientMetadata; }, + clientInformation: jest.fn(), + tokens: jest.fn(), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + initialAccessToken: jest.fn().mockResolvedValue("provider-token"), + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validClientInfo, + }); + + await registerClient("https://auth.example.com", { + clientMetadata: validClientMetadata, + provider: mockProvider, + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/register", + }), + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer provider-token", + }, + body: JSON.stringify(validClientMetadata), + }) + ); + }); + + it("prioritizes explicit parameter over provider method", async () => { + const mockProvider: OAuthClientProvider = { + get redirectUrl() { return "http://localhost:3000/callback"; }, + get clientMetadata() { return validClientMetadata; }, + clientInformation: jest.fn(), + tokens: jest.fn(), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + initialAccessToken: jest.fn().mockResolvedValue("provider-token"), + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validClientInfo, + }); + + await registerClient("https://auth.example.com", { + clientMetadata: validClientMetadata, + initialAccessToken: "explicit-token", + provider: mockProvider, + }); + + expect(mockProvider.initialAccessToken).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/register", + }), + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer explicit-token", + }, + body: JSON.stringify(validClientMetadata), + }) + ); + }); + + it("registers without authorization header when no token available", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validClientInfo, + }); + + await registerClient("https://auth.example.com", { + clientMetadata: validClientMetadata, + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/register", + }), + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(validClientMetadata), + }) + ); + }); + }); }); describe("auth function", () => { diff --git a/src/client/auth.ts b/src/client/auth.ts index 4a8bbe2d2..a3e937cb2 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -124,6 +124,17 @@ export interface OAuthClientProvider { * This avoids requiring the user to intervene manually. */ invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise; + + /** + * If implemented, provides an initial access token for OAuth 2.0 Dynamic Client Registration + * according to RFC 7591. This token is used to authorize the client registration request. + * + * The initial access token allows the client to register with authorization servers that + * require pre-authorization for dynamic client registration. + * + * @returns The initial access token string, or undefined if none is available + */ + initialAccessToken?(): string | undefined | Promise; } export type AuthResult = "AUTHORIZED" | "REDIRECT"; @@ -281,7 +292,8 @@ export async function auth( serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL }): Promise { + resourceMetadataUrl?: URL; + initialAccessToken?: string; }): Promise { try { return await authInternal(provider, options); @@ -305,12 +317,14 @@ async function authInternal( { serverUrl, authorizationCode, scope, - resourceMetadataUrl + resourceMetadataUrl, + initialAccessToken }: { serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL + resourceMetadataUrl?: URL; + initialAccessToken?: string; }): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; @@ -344,6 +358,8 @@ async function authInternal( const fullInformation = await registerClient(authorizationServerUrl, { metadata, clientMetadata: provider.clientMetadata, + initialAccessToken, + provider, }); await provider.saveClientInformation(fullInformation); @@ -877,15 +893,28 @@ export async function refreshAuthorization( /** * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. + * + * Supports initial access tokens for authorization servers that require + * pre-authorization for dynamic client registration. The initial access token + * is resolved using a multi-level fallback approach: + * + * 1. Explicit `initialAccessToken` parameter (highest priority) + * 2. Provider's `initialAccessToken()` method (if implemented) + * 3. `OAUTH_INITIAL_ACCESS_TOKEN` environment variable + * 4. None (current behavior for servers that don't require pre-authorization) */ export async function registerClient( authorizationServerUrl: string | URL, { metadata, clientMetadata, + initialAccessToken, + provider, }: { metadata?: OAuthMetadata; clientMetadata: OAuthClientMetadata; + initialAccessToken?: string; + provider?: OAuthClientProvider; }, ): Promise { let registrationUrl: URL; @@ -900,11 +929,33 @@ export async function registerClient( registrationUrl = new URL("/register", authorizationServerUrl); } + // Multi-level fallback for initial access token + let token = initialAccessToken; // Level 1: Explicit parameter + + if (!token && provider?.initialAccessToken) { + // Level 2: Provider method + token = await Promise.resolve(provider.initialAccessToken()); + } + + // Level 3: Environment variable (Node.js environments only) + if (!token && typeof globalThis !== 'undefined' && (globalThis as any).process?.env) { + token = (globalThis as any).process.env.OAUTH_INITIAL_ACCESS_TOKEN; + } + + // Level 4: None (current behavior) - no token needed + + const headers: Record = { + "Content-Type": "application/json", + }; + + // Add initial access token if available (RFC 7591) + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + const response = await fetch(registrationUrl, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers, body: JSON.stringify(clientMetadata), }); diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 2cc4a1dd7..d8cadfbd3 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -1107,5 +1107,80 @@ describe("SSEClientTransport", () => { await expect(() => transport.start()).rejects.toThrow(InvalidGrantError); expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens'); }); + + describe("initialAccessToken support", () => { + it("stores initialAccessToken from constructor options", () => { + const transport = new SSEClientTransport( + new URL("http://localhost:1234/mcp"), + { initialAccessToken: "test-initial-token" } + ); + + // Access private property for testing + const transportInstance = transport as unknown as { _initialAccessToken?: string }; + expect(transportInstance._initialAccessToken).toBe("test-initial-token"); + }); + + it("works without initialAccessToken (backward compatibility)", async () => { + const transport = new SSEClientTransport( + new URL("http://localhost:1234/mcp"), + { authProvider: mockAuthProvider } + ); + + const transportInstance = transport as unknown as { _initialAccessToken?: string }; + expect(transportInstance._initialAccessToken).toBeUndefined(); + + // Should not throw when no initial access token provided + expect(() => transport).not.toThrow(); + }); + + it("includes initialAccessToken in auth calls", async () => { + // Create a spy on the auth module + const authModule = await import("./auth.js"); + const authSpy = jest.spyOn(authModule, "auth").mockResolvedValue("REDIRECT"); + + const transport = new SSEClientTransport( + resourceBaseUrl, + { + authProvider: mockAuthProvider, + initialAccessToken: "test-initial-token" + } + ); + + // Start the transport first + await transport.start(); + + // Mock fetch to return 401 and trigger auth on send + const originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 401, + headers: new Headers(), + }); + + const message = { + jsonrpc: "2.0" as const, + method: "test", + params: {}, + id: "test-id" + }; + + try { + await transport.send(message); + } catch { + // Expected to fail due to mock setup, we're just testing auth call + } + + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + initialAccessToken: "test-initial-token" + }) + ); + + // Restore fetch and spy + global.fetch = originalFetch; + authSpy.mockRestore(); + }); + }); }); }); diff --git a/src/client/sse.ts b/src/client/sse.ts index 568a51592..98484bfec 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -52,6 +52,16 @@ export type SSEClientTransportOptions = { * Custom fetch implementation used for all network requests. */ fetch?: FetchLike; + + /** + * Initial access token for OAuth 2.0 Dynamic Client Registration (RFC 7591). + * This token is used to authorize the client registration request with authorization servers + * that require pre-authorization for dynamic client registration. + * + * If not provided, the system will fall back to the provider's `initialAccessToken()` method + * and then to the `OAUTH_INITIAL_ACCESS_TOKEN` environment variable. + */ + initialAccessToken?: string; }; /** @@ -69,6 +79,7 @@ export class SSEClientTransport implements Transport { private _authProvider?: OAuthClientProvider; private _fetch?: FetchLike; private _protocolVersion?: string; + private _initialAccessToken?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -84,6 +95,7 @@ export class SSEClientTransport implements Transport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; + this._initialAccessToken = opts?.initialAccessToken; } private async _authThenStart(): Promise { @@ -93,7 +105,7 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, initialAccessToken: this._initialAccessToken }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -218,7 +230,7 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, initialAccessToken: this._initialAccessToken }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -252,7 +264,7 @@ const response = await (this._fetch ?? fetch)(this._endpoint, init); this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, initialAccessToken: this._initialAccessToken }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index c54cf2896..baeb955be 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -855,4 +855,73 @@ describe("StreamableHTTPClientTransport", () => { await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens'); }); + + describe("initialAccessToken support", () => { + it("stores initialAccessToken from constructor options", () => { + const transport = new StreamableHTTPClientTransport( + new URL("http://localhost:1234/mcp"), + { initialAccessToken: "test-initial-token" } + ); + + // Access private property for testing + const transportInstance = transport as unknown as { _initialAccessToken?: string }; + expect(transportInstance._initialAccessToken).toBe("test-initial-token"); + }); + + it("works without initialAccessToken (backward compatibility)", async () => { + const transport = new StreamableHTTPClientTransport( + new URL("http://localhost:1234/mcp"), + { authProvider: mockAuthProvider } + ); + + const transportInstance = transport as unknown as { _initialAccessToken?: string }; + expect(transportInstance._initialAccessToken).toBeUndefined(); + + // Should not throw when no initial access token provided + expect(() => transport).not.toThrow(); + }); + + it("includes initialAccessToken in auth calls", async () => { + // Create a spy on the auth module + const authModule = await import("./auth.js"); + const authSpy = jest.spyOn(authModule, "auth").mockResolvedValue("REDIRECT"); + + const transport = new StreamableHTTPClientTransport( + new URL("http://localhost:1234/mcp"), + { + authProvider: mockAuthProvider, + initialAccessToken: "test-initial-token" + } + ); + + // Mock fetch to trigger auth flow on send (401 response) + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + headers: new Headers(), + }); + + const message = { + jsonrpc: "2.0" as const, + method: "test", + params: {}, + id: "test-id" + }; + + try { + await transport.send(message); + } catch { + // Expected to fail due to mock setup, we're just testing auth call + } + + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + initialAccessToken: "test-initial-token" + }) + ); + + authSpy.mockRestore(); + }); + }); }); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index b0894fce1..a79037224 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -114,6 +114,14 @@ export type StreamableHTTPClientTransportOptions = { * When not provided and connecting to a server that supports session IDs, the server will generate a new session ID. */ sessionId?: string; + + /** + * Initial access token for OAuth 2.0 Dynamic Client Registration (RFC 7591). + * This token is used to authorize the client registration request with authorization servers that require pre-authorization for dynamic client registration. + * + * If not provided, the system will fall back to the provider's `initialAccessToken()` method and then to the `OAUTH_INITIAL_ACCESS_TOKEN` environment variable. + */ + initialAccessToken?: string; }; /** @@ -131,6 +139,7 @@ export class StreamableHTTPClientTransport implements Transport { private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; private _protocolVersion?: string; + private _initialAccessToken?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -147,6 +156,7 @@ export class StreamableHTTPClientTransport implements Transport { this._fetch = opts?.fetch; this._sessionId = opts?.sessionId; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; + this._initialAccessToken = opts?.initialAccessToken; } private async _authThenStart(): Promise { @@ -156,7 +166,7 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, initialAccessToken: this._initialAccessToken }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -392,7 +402,7 @@ const response = await (this._fetch ?? fetch)(this._url, { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, initialAccessToken: this._initialAccessToken }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -440,7 +450,7 @@ const response = await (this._fetch ?? fetch)(this._url, init); this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, initialAccessToken: this._initialAccessToken }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } From 762ba592b26856ec5cc7e39cedc6e93427e2444b Mon Sep 17 00:00:00 2001 From: Andor Markus Date: Wed, 16 Jul 2025 11:58:55 +0200 Subject: [PATCH 2/6] chore: remove .idea files from tracking IDE-specific files should not be committed to the repository --- .idea/.gitignore | 8 -------- .idea/inspectionProfiles/profiles_settings.xml | 6 ------ .idea/misc.xml | 7 ------- .idea/modules.xml | 8 -------- .idea/typescript-sdk.iml | 8 -------- .idea/vcs.xml | 6 ------ 6 files changed, 43 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/typescript-sdk.iml delete mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b81b..000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2da2..000000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 44664d9f6..000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index f6e4d6a29..000000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/typescript-sdk.iml b/.idea/typescript-sdk.iml deleted file mode 100644 index 67f8478ce..000000000 --- a/.idea/typescript-sdk.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddfb..000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From 33be76448f4433c3d5f388ac646ddcafd2608658 Mon Sep 17 00:00:00 2001 From: Andor Markus Date: Wed, 16 Jul 2025 13:03:51 +0200 Subject: [PATCH 3/6] feat: add multi-level initial access token support for OAuth 2.0 Dynamic Client Registration (RFC 7591) - Extend OAuthClientProvider interface with optional initialAccessToken() method - Update registerClient() to support multi-level fallback: 1. Explicit parameter (highest priority) 2. Provider method 3. OAUTH_INITIAL_ACCESS_TOKEN environment variable 4. None (existing behavior) - Add initialAccessToken option to StreamableHTTPClientTransport and SSEClientTransport - Update auth flow to pass initial access token through all transport layers - Add Authorization: Bearer header to registration requests when token available - Add comprehensive test coverage for all fallback levels - Add detailed OAuth client configuration documentation - Maintain backward compatibility with servers not requiring pre-authorization Implements RFC 7591 specification for OAuth 2.0 Dynamic Client Registration with initial access tokens for authorization servers requiring pre-authorization. --- README.md | 130 +++++++++++++++++++++++++++++++++++++++++ src/examples/README.md | 4 ++ 2 files changed, 134 insertions(+) diff --git a/README.md b/README.md index 4684c67c7..fba6ab25e 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - [Dynamic Servers](#dynamic-servers) - [Low-Level Server](#low-level-server) - [Writing MCP Clients](#writing-mcp-clients) + - [OAuth Client Configuration](#oauth-client-configuration) - [Proxy Authorization Requests Upstream](#proxy-authorization-requests-upstream) - [Backwards Compatibility](#backwards-compatibility) - [Documentation](#documentation) @@ -1162,6 +1163,135 @@ const result = await client.callTool({ ``` +### OAuth Client Configuration + +The MCP SDK provides comprehensive OAuth 2.0 client support with dynamic client registration and multiple authentication methods. + +#### Basic OAuth Client Setup + +```typescript +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; + +class MyOAuthProvider implements OAuthClientProvider { + get redirectUrl() { return "http://localhost:3000/callback"; } + + get clientMetadata() { + return { + redirect_uris: ["http://localhost:3000/callback"], + client_name: "My MCP Client", + scope: "mcp:tools mcp:resources" + }; + } + + async clientInformation() { + // Return stored client info or undefined for dynamic registration + return this.loadClientInfo(); + } + + async saveClientInformation(info) { + // Store client info after registration + await this.storeClientInfo(info); + } + + async tokens() { + // Return stored tokens or undefined + return this.loadTokens(); + } + + async saveTokens(tokens) { + // Store OAuth tokens + await this.storeTokens(tokens); + } + + async redirectToAuthorization(url) { + // Redirect user to authorization URL + window.location.href = url.toString(); + } + + async saveCodeVerifier(verifier) { + // Store PKCE code verifier + sessionStorage.setItem('code_verifier', verifier); + } + + async codeVerifier() { + // Return stored code verifier + return sessionStorage.getItem('code_verifier'); + } +} + +const authProvider = new MyOAuthProvider(); +const transport = new StreamableHTTPClientTransport(serverUrl, { + authProvider +}); + +const client = new Client({ name: "oauth-client", version: "1.0.0" }); +await client.connect(transport); +``` + +#### Initial Access Token Support (RFC 7591) + +For authorization servers that require pre-authorization for dynamic client registration, the SDK supports initial access tokens with multi-level fallback: + +##### Method 1: Transport Configuration (Highest Priority) +```typescript +const transport = new StreamableHTTPClientTransport(serverUrl, { + authProvider, + initialAccessToken: "your-initial-access-token" +}); +``` + +##### Method 2: Provider Method +```typescript +class MyOAuthProvider implements OAuthClientProvider { + // ... other methods ... + + async initialAccessToken() { + // Load from secure storage, API call, etc. + return await this.loadFromSecureStorage('initial_access_token'); + } +} +``` + +##### Method 3: Environment Variable +```bash +export OAUTH_INITIAL_ACCESS_TOKEN="your-initial-access-token" +``` + +The SDK will automatically try these methods in order: +1. Explicit `initialAccessToken` parameter (highest priority) +2. Provider's `initialAccessToken()` method +3. `OAUTH_INITIAL_ACCESS_TOKEN` environment variable +4. None (for servers that don't require pre-authorization) + +#### Complete OAuth Flow Example + +```typescript +// After user authorization, handle the callback +async function handleAuthCallback(authorizationCode: string) { + await transport.finishAuth(authorizationCode); + // Client is now authenticated and ready to use + + const result = await client.callTool({ + name: "example-tool", + arguments: { param: "value" } + }); +} + +// Start the OAuth flow +try { + await client.connect(transport); + console.log("Already authenticated"); +} catch (error) { + if (error instanceof UnauthorizedError) { + console.log("OAuth authorization required"); + // User will be redirected to authorization server + // Handle the callback when they return + } +} +``` + ### Proxy Authorization Requests Upstream You can proxy OAuth requests to an external authorization provider: diff --git a/src/examples/README.md b/src/examples/README.md index ac92e8ded..655e98912 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -39,6 +39,10 @@ Example client with OAuth: npx tsx src/examples/client/simpleOAuthClient.js ``` +The OAuth client example supports initial access tokens for dynamic client registration (RFC 7591). You can provide the token via: +- Environment variable: `export OAUTH_INITIAL_ACCESS_TOKEN="your-token"` +- Transport configuration (see source code for examples) + ### Backwards Compatible Client A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates: From 986bd46c4c28f6d937c1a7c7065a44c38762f5aa Mon Sep 17 00:00:00 2001 From: Andor Markus Date: Thu, 24 Jul 2025 15:10:20 +0200 Subject: [PATCH 4/6] feat: rename initial access token to DCR registration access token - Rename OAuthClientProvider.initialAccessToken() to dcrRegistrationAccessToken() - Update environment variable from OAUTH_INITIAL_ACCESS_TOKEN to DCR_REGISTRATION_ACCESS_TOKEN - Update all documentation and comments to use DCR terminology - Add clarifications that RFC 7591 calls this "initial access token" - Maintains RFC 7591 compliance while using more specific SDK terminology Addresses PR feedback to use clearer naming for Dynamic Client Registration tokens. --- README.md | 16 ++++++++-------- src/client/auth.ts | 26 +++++++++++++------------- src/examples/README.md | 4 ++-- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index fba6ab25e..c02381074 100644 --- a/README.md +++ b/README.md @@ -1230,15 +1230,15 @@ const client = new Client({ name: "oauth-client", version: "1.0.0" }); await client.connect(transport); ``` -#### Initial Access Token Support (RFC 7591) +#### DCR Registration Access Token Support (RFC 7591) -For authorization servers that require pre-authorization for dynamic client registration, the SDK supports initial access tokens with multi-level fallback: +For authorization servers that require pre-authorization for dynamic client registration, the SDK supports DCR registration access tokens (called "initial access token" in RFC 7591) with multi-level fallback: ##### Method 1: Transport Configuration (Highest Priority) ```typescript const transport = new StreamableHTTPClientTransport(serverUrl, { authProvider, - initialAccessToken: "your-initial-access-token" + initialAccessToken: "your-dcr-registration-access-token" }); ``` @@ -1247,22 +1247,22 @@ const transport = new StreamableHTTPClientTransport(serverUrl, { class MyOAuthProvider implements OAuthClientProvider { // ... other methods ... - async initialAccessToken() { + async dcrRegistrationAccessToken() { // Load from secure storage, API call, etc. - return await this.loadFromSecureStorage('initial_access_token'); + return await this.loadFromSecureStorage('dcr_registration_access_token'); } } ``` ##### Method 3: Environment Variable ```bash -export OAUTH_INITIAL_ACCESS_TOKEN="your-initial-access-token" +export DCR_REGISTRATION_ACCESS_TOKEN="your-dcr-registration-access-token" ``` The SDK will automatically try these methods in order: 1. Explicit `initialAccessToken` parameter (highest priority) -2. Provider's `initialAccessToken()` method -3. `OAUTH_INITIAL_ACCESS_TOKEN` environment variable +2. Provider's `dcrRegistrationAccessToken()` method +3. `DCR_REGISTRATION_ACCESS_TOKEN` environment variable 4. None (for servers that don't require pre-authorization) #### Complete OAuth Flow Example diff --git a/src/client/auth.ts b/src/client/auth.ts index a3e937cb2..99552bb07 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -126,15 +126,15 @@ export interface OAuthClientProvider { invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise; /** - * If implemented, provides an initial access token for OAuth 2.0 Dynamic Client Registration + * If implemented, provides a DCR registration access token (called "initial access token" in RFC 7591) for OAuth 2.0 Dynamic Client Registration * according to RFC 7591. This token is used to authorize the client registration request. * - * The initial access token allows the client to register with authorization servers that + * The DCR registration access token allows the client to register with authorization servers that * require pre-authorization for dynamic client registration. * - * @returns The initial access token string, or undefined if none is available + * @returns The DCR registration access token string, or undefined if none is available */ - initialAccessToken?(): string | undefined | Promise; + dcrRegistrationAccessToken?(): string | undefined | Promise; } export type AuthResult = "AUTHORIZED" | "REDIRECT"; @@ -894,13 +894,13 @@ export async function refreshAuthorization( /** * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. * - * Supports initial access tokens for authorization servers that require - * pre-authorization for dynamic client registration. The initial access token + * Supports DCR registration access tokens (called "initial access token" in RFC 7591) for authorization servers that require + * pre-authorization for dynamic client registration. The DCR registration access token * is resolved using a multi-level fallback approach: * * 1. Explicit `initialAccessToken` parameter (highest priority) - * 2. Provider's `initialAccessToken()` method (if implemented) - * 3. `OAUTH_INITIAL_ACCESS_TOKEN` environment variable + * 2. Provider's `dcrRegistrationAccessToken()` method (if implemented) + * 3. `DCR_REGISTRATION_ACCESS_TOKEN` environment variable * 4. None (current behavior for servers that don't require pre-authorization) */ export async function registerClient( @@ -929,17 +929,17 @@ export async function registerClient( registrationUrl = new URL("/register", authorizationServerUrl); } - // Multi-level fallback for initial access token + // Multi-level fallback for DCR registration access token (RFC 7591 "initial access token") let token = initialAccessToken; // Level 1: Explicit parameter - if (!token && provider?.initialAccessToken) { + if (!token && provider?.dcrRegistrationAccessToken) { // Level 2: Provider method - token = await Promise.resolve(provider.initialAccessToken()); + token = await Promise.resolve(provider.dcrRegistrationAccessToken()); } // Level 3: Environment variable (Node.js environments only) if (!token && typeof globalThis !== 'undefined' && (globalThis as any).process?.env) { - token = (globalThis as any).process.env.OAUTH_INITIAL_ACCESS_TOKEN; + token = (globalThis as any).process.env.DCR_REGISTRATION_ACCESS_TOKEN; } // Level 4: None (current behavior) - no token needed @@ -948,7 +948,7 @@ export async function registerClient( "Content-Type": "application/json", }; - // Add initial access token if available (RFC 7591) + // Add DCR registration access token (RFC 7591 "initial access token") if available if (token) { headers["Authorization"] = `Bearer ${token}`; } diff --git a/src/examples/README.md b/src/examples/README.md index 655e98912..f5523fa9a 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -39,8 +39,8 @@ Example client with OAuth: npx tsx src/examples/client/simpleOAuthClient.js ``` -The OAuth client example supports initial access tokens for dynamic client registration (RFC 7591). You can provide the token via: -- Environment variable: `export OAUTH_INITIAL_ACCESS_TOKEN="your-token"` +The OAuth client example supports DCR registration access tokens (called "initial access token" in RFC 7591) for dynamic client registration. You can provide the token via: +- Environment variable: `export DCR_REGISTRATION_ACCESS_TOKEN="your-token"` - Transport configuration (see source code for examples) ### Backwards Compatible Client From 1fd8a9ef286a74d3d3f4fa51e7888c36e4c7eed9 Mon Sep 17 00:00:00 2001 From: Andor Markus Date: Thu, 24 Jul 2025 18:55:09 +0200 Subject: [PATCH 5/6] feat: simplify DCR registration access token to 2-level fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Removed initialAccessToken parameters from transport constructors - Rename initialAccessToken() to dcrRegistrationAccessToken() in OAuthClientProvider - Remove 3-level fallback complexity per PR feedback - Consolidate DCR token logic in auth.ts with resolveDcrToken() helper - Remove initialAccessToken options from StreamableHTTP and SSE transports - Keep environment variable DCR_REGISTRATION_ACCESS_TOKEN as automatic fallback - Update all documentation to use DCR terminology with RFC 7591 clarifications - Update tests to reflect new 2-level approach: provider method → env var - Users can implement custom fallback logic in their OAuthProvider Addresses PR feedback: "I don't think we want 3 levels of fallback. Users can implement their own OAuthProvider, and that provider can fallback to parameters or environment variables, but this is a relatively narrow use case so I don't think it warrants having special handling code in all the different places." RFC 7591 compliance maintained. Environment variable support preserved for convenience. --- README.md | 35 +++-- src/client/auth.test.ts | 207 ++++++++++++++++++++---------- src/client/auth.ts | 56 ++++---- src/client/sse.test.ts | 105 ++++++++------- src/client/sse.ts | 18 +-- src/client/streamableHttp.test.ts | 121 +++++++++++++---- src/client/streamableHttp.ts | 16 +-- src/examples/README.md | 4 +- 8 files changed, 339 insertions(+), 223 deletions(-) diff --git a/README.md b/README.md index c02381074..96c3e5cfd 100644 --- a/README.md +++ b/README.md @@ -1232,38 +1232,33 @@ await client.connect(transport); #### DCR Registration Access Token Support (RFC 7591) -For authorization servers that require pre-authorization for dynamic client registration, the SDK supports DCR registration access tokens (called "initial access token" in RFC 7591) with multi-level fallback: +For authorization servers that require pre-authorization for dynamic client registration, the SDK supports DCR registration access tokens (called "initial access token" in RFC 7591). -##### Method 1: Transport Configuration (Highest Priority) -```typescript -const transport = new StreamableHTTPClientTransport(serverUrl, { - authProvider, - initialAccessToken: "your-dcr-registration-access-token" -}); +The SDK automatically checks for a `DCR_REGISTRATION_ACCESS_TOKEN` environment variable. For custom logic, implement the `dcrRegistrationAccessToken()` method in your OAuth provider: + +##### Method 1: Environment Variable (Default) +```bash +export DCR_REGISTRATION_ACCESS_TOKEN="your-dcr-registration-access-token" ``` -##### Method 2: Provider Method +##### Method 2: Custom Provider Method ```typescript class MyOAuthProvider implements OAuthClientProvider { // ... other methods ... async dcrRegistrationAccessToken() { - // Load from secure storage, API call, etc. - return await this.loadFromSecureStorage('dcr_registration_access_token'); + // Custom fallback logic: check parameter, then env var, then storage + return this.explicitToken + || process.env.DCR_REGISTRATION_ACCESS_TOKEN + || await this.loadFromSecureStorage('dcr_registration_access_token'); } } ``` -##### Method 3: Environment Variable -```bash -export DCR_REGISTRATION_ACCESS_TOKEN="your-dcr-registration-access-token" -``` - -The SDK will automatically try these methods in order: -1. Explicit `initialAccessToken` parameter (highest priority) -2. Provider's `dcrRegistrationAccessToken()` method -3. `DCR_REGISTRATION_ACCESS_TOKEN` environment variable -4. None (for servers that don't require pre-authorization) +The SDK will: +1. Call your `dcrRegistrationAccessToken()` method (if implemented) +2. Fall back to `DCR_REGISTRATION_ACCESS_TOKEN` environment variable +3. Proceed without token (for servers that don't require pre-authorization) #### Complete OAuth Flow Example diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index eb26abc45..693405067 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1159,35 +1159,8 @@ describe("OAuth Authorization", () => { ).rejects.toThrow("Dynamic client registration failed"); }); - describe("initial access token support", () => { - it("includes initial access token from explicit parameter", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => validClientInfo, - }); - - await registerClient("https://auth.example.com", { - clientMetadata: validClientMetadata, - initialAccessToken: "explicit-token", - }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ - href: "https://auth.example.com/register", - }), - expect.objectContaining({ - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer explicit-token", - }, - body: JSON.stringify(validClientMetadata), - }) - ); - }); - - it("includes initial access token from provider method", async () => { + describe("DCR registration access token support", () => { + it("includes DCR token from provider method", async () => { const mockProvider: OAuthClientProvider = { get redirectUrl() { return "http://localhost:3000/callback"; }, get clientMetadata() { return validClientMetadata; }, @@ -1197,7 +1170,7 @@ describe("OAuth Authorization", () => { redirectToAuthorization: jest.fn(), saveCodeVerifier: jest.fn(), codeVerifier: jest.fn(), - initialAccessToken: jest.fn().mockResolvedValue("provider-token"), + dcrRegistrationAccessToken: jest.fn().mockResolvedValue("provider-dcr-token"), }; mockFetch.mockResolvedValueOnce({ @@ -1211,6 +1184,7 @@ describe("OAuth Authorization", () => { provider: mockProvider, }); + expect(mockProvider.dcrRegistrationAccessToken).toHaveBeenCalled(); expect(mockFetch).toHaveBeenCalledWith( expect.objectContaining({ href: "https://auth.example.com/register", @@ -1219,52 +1193,151 @@ describe("OAuth Authorization", () => { method: "POST", headers: { "Content-Type": "application/json", - "Authorization": "Bearer provider-token", + "Authorization": "Bearer provider-dcr-token", }, body: JSON.stringify(validClientMetadata), }) ); }); - it("prioritizes explicit parameter over provider method", async () => { - const mockProvider: OAuthClientProvider = { - get redirectUrl() { return "http://localhost:3000/callback"; }, - get clientMetadata() { return validClientMetadata; }, - clientInformation: jest.fn(), - tokens: jest.fn(), - saveTokens: jest.fn(), - redirectToAuthorization: jest.fn(), - saveCodeVerifier: jest.fn(), - codeVerifier: jest.fn(), - initialAccessToken: jest.fn().mockResolvedValue("provider-token"), - }; + it("falls back to environment variable when provider method not implemented", async () => { + const originalEnv = process.env.DCR_REGISTRATION_ACCESS_TOKEN; + process.env.DCR_REGISTRATION_ACCESS_TOKEN = "env-dcr-token"; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => validClientInfo, - }); + try { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validClientInfo, + }); - await registerClient("https://auth.example.com", { - clientMetadata: validClientMetadata, - initialAccessToken: "explicit-token", - provider: mockProvider, - }); + await registerClient("https://auth.example.com", { + clientMetadata: validClientMetadata, + // No provider passed + }); - expect(mockProvider.initialAccessToken).not.toHaveBeenCalled(); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ - href: "https://auth.example.com/register", - }), - expect.objectContaining({ - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer explicit-token", - }, - body: JSON.stringify(validClientMetadata), - }) - ); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/register", + }), + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer env-dcr-token", + }, + body: JSON.stringify(validClientMetadata), + }) + ); + } finally { + if (originalEnv !== undefined) { + process.env.DCR_REGISTRATION_ACCESS_TOKEN = originalEnv; + } else { + delete process.env.DCR_REGISTRATION_ACCESS_TOKEN; + } + } + }); + + it("prioritizes provider method over environment variable", async () => { + const originalEnv = process.env.DCR_REGISTRATION_ACCESS_TOKEN; + process.env.DCR_REGISTRATION_ACCESS_TOKEN = "env-dcr-token"; + + try { + const mockProvider: OAuthClientProvider = { + get redirectUrl() { return "http://localhost:3000/callback"; }, + get clientMetadata() { return validClientMetadata; }, + clientInformation: jest.fn(), + tokens: jest.fn(), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + dcrRegistrationAccessToken: jest.fn().mockResolvedValue("provider-dcr-token"), + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validClientInfo, + }); + + await registerClient("https://auth.example.com", { + clientMetadata: validClientMetadata, + provider: mockProvider, + }); + + expect(mockProvider.dcrRegistrationAccessToken).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/register", + }), + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer provider-dcr-token", + }, + body: JSON.stringify(validClientMetadata), + }) + ); + } finally { + if (originalEnv !== undefined) { + process.env.DCR_REGISTRATION_ACCESS_TOKEN = originalEnv; + } else { + delete process.env.DCR_REGISTRATION_ACCESS_TOKEN; + } + } + }); + + it("handles provider method returning undefined and falls back to env var", async () => { + const originalEnv = process.env.DCR_REGISTRATION_ACCESS_TOKEN; + process.env.DCR_REGISTRATION_ACCESS_TOKEN = "env-dcr-token"; + + try { + const mockProvider: OAuthClientProvider = { + get redirectUrl() { return "http://localhost:3000/callback"; }, + get clientMetadata() { return validClientMetadata; }, + clientInformation: jest.fn(), + tokens: jest.fn(), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + dcrRegistrationAccessToken: jest.fn().mockResolvedValue(undefined), + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validClientInfo, + }); + + await registerClient("https://auth.example.com", { + clientMetadata: validClientMetadata, + provider: mockProvider, + }); + + expect(mockProvider.dcrRegistrationAccessToken).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/register", + }), + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer env-dcr-token", + }, + body: JSON.stringify(validClientMetadata), + }) + ); + } finally { + if (originalEnv !== undefined) { + process.env.DCR_REGISTRATION_ACCESS_TOKEN = originalEnv; + } else { + delete process.env.DCR_REGISTRATION_ACCESS_TOKEN; + } + } }); it("registers without authorization header when no token available", async () => { diff --git a/src/client/auth.ts b/src/client/auth.ts index 99552bb07..323cec2fe 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -292,8 +292,7 @@ export async function auth( serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL; - initialAccessToken?: string; }): Promise { + resourceMetadataUrl?: URL }): Promise { try { return await authInternal(provider, options); @@ -317,14 +316,12 @@ async function authInternal( { serverUrl, authorizationCode, scope, - resourceMetadataUrl, - initialAccessToken + resourceMetadataUrl }: { serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL; - initialAccessToken?: string; + resourceMetadataUrl?: URL }): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; @@ -358,7 +355,6 @@ async function authInternal( const fullInformation = await registerClient(authorizationServerUrl, { metadata, clientMetadata: provider.clientMetadata, - initialAccessToken, provider, }); @@ -896,24 +892,21 @@ export async function refreshAuthorization( * * Supports DCR registration access tokens (called "initial access token" in RFC 7591) for authorization servers that require * pre-authorization for dynamic client registration. The DCR registration access token - * is resolved using a multi-level fallback approach: + * is resolved using a clean 2-level fallback approach: * - * 1. Explicit `initialAccessToken` parameter (highest priority) - * 2. Provider's `dcrRegistrationAccessToken()` method (if implemented) - * 3. `DCR_REGISTRATION_ACCESS_TOKEN` environment variable - * 4. None (current behavior for servers that don't require pre-authorization) + * 1. Provider's `dcrRegistrationAccessToken()` method (if implemented) + * 2. `DCR_REGISTRATION_ACCESS_TOKEN` environment variable (automatic fallback) + * 3. None (for servers that don't require pre-authorization) */ export async function registerClient( authorizationServerUrl: string | URL, { metadata, clientMetadata, - initialAccessToken, provider, }: { metadata?: OAuthMetadata; clientMetadata: OAuthClientMetadata; - initialAccessToken?: string; provider?: OAuthClientProvider; }, ): Promise { @@ -929,26 +922,12 @@ export async function registerClient( registrationUrl = new URL("/register", authorizationServerUrl); } - // Multi-level fallback for DCR registration access token (RFC 7591 "initial access token") - let token = initialAccessToken; // Level 1: Explicit parameter - - if (!token && provider?.dcrRegistrationAccessToken) { - // Level 2: Provider method - token = await Promise.resolve(provider.dcrRegistrationAccessToken()); - } - - // Level 3: Environment variable (Node.js environments only) - if (!token && typeof globalThis !== 'undefined' && (globalThis as any).process?.env) { - token = (globalThis as any).process.env.DCR_REGISTRATION_ACCESS_TOKEN; - } - - // Level 4: None (current behavior) - no token needed - const headers: Record = { "Content-Type": "application/json", }; // Add DCR registration access token (RFC 7591 "initial access token") if available + const token = await resolveDcrToken(provider); if (token) { headers["Authorization"] = `Bearer ${token}`; } @@ -965,3 +944,22 @@ export async function registerClient( return OAuthClientInformationFullSchema.parse(await response.json()); } + +/** + * Internal helper to resolve DCR registration access token from provider and environment. + * Implements a clean 2-level fallback: provider method → environment variable. + */ +async function resolveDcrToken(provider?: OAuthClientProvider): Promise { + // Level 1: Provider method + if (provider?.dcrRegistrationAccessToken) { + const token = await Promise.resolve(provider.dcrRegistrationAccessToken()); + if (token) return token; + } + + // Level 2: Environment variable + if (typeof process !== 'undefined' && process.env?.DCR_REGISTRATION_ACCESS_TOKEN) { + return process.env.DCR_REGISTRATION_ACCESS_TOKEN; + } + + return undefined; +} diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index d8cadfbd3..e45ec6ab3 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -1108,77 +1108,76 @@ describe("SSEClientTransport", () => { expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens'); }); - describe("initialAccessToken support", () => { - it("stores initialAccessToken from constructor options", () => { - const transport = new SSEClientTransport( - new URL("http://localhost:1234/mcp"), - { initialAccessToken: "test-initial-token" } + describe("DCR registration access token support via provider", () => { + it("calls auth without initialAccessToken parameter when using provider with DCR method", async () => { + // Create a mock provider with DCR token method + const providerWithDcr = { + ...mockAuthProvider, + dcrRegistrationAccessToken: jest.fn().mockResolvedValue("provider-dcr-token") + }; + + // Create a spy on the auth module + const authModule = await import("./auth.js"); + const authSpy = jest.spyOn(authModule, "auth").mockResolvedValue("AUTHORIZED"); + + transport = new SSEClientTransport( + resourceBaseUrl, + { authProvider: providerWithDcr } ); - - // Access private property for testing - const transportInstance = transport as unknown as { _initialAccessToken?: string }; - expect(transportInstance._initialAccessToken).toBe("test-initial-token"); - }); - it("works without initialAccessToken (backward compatibility)", async () => { - const transport = new SSEClientTransport( - new URL("http://localhost:1234/mcp"), - { authProvider: mockAuthProvider } + // Test finishAuth method which calls auth directly + await transport.finishAuth("test-auth-code"); + + // Verify auth was called without initialAccessToken parameter + expect(authSpy).toHaveBeenCalledWith( + providerWithDcr, + expect.objectContaining({ + serverUrl: resourceBaseUrl, + authorizationCode: "test-auth-code" + }) + ); + + // Verify the deprecated parameter is NOT included + expect(authSpy).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + initialAccessToken: expect.anything() + }) ); - - const transportInstance = transport as unknown as { _initialAccessToken?: string }; - expect(transportInstance._initialAccessToken).toBeUndefined(); - // Should not throw when no initial access token provided - expect(() => transport).not.toThrow(); + authSpy.mockRestore(); }); - it("includes initialAccessToken in auth calls", async () => { - // Create a spy on the auth module + it("calls auth without initialAccessToken parameter when using provider without DCR method", async () => { + // Use the regular mock provider (no DCR method) const authModule = await import("./auth.js"); - const authSpy = jest.spyOn(authModule, "auth").mockResolvedValue("REDIRECT"); + const authSpy = jest.spyOn(authModule, "auth").mockResolvedValue("AUTHORIZED"); - const transport = new SSEClientTransport( + transport = new SSEClientTransport( resourceBaseUrl, - { - authProvider: mockAuthProvider, - initialAccessToken: "test-initial-token" - } + { authProvider: mockAuthProvider } ); - // Start the transport first - await transport.start(); - - // Mock fetch to return 401 and trigger auth on send - const originalFetch = global.fetch; - global.fetch = jest.fn().mockResolvedValueOnce({ - ok: false, - status: 401, - headers: new Headers(), - }); - - const message = { - jsonrpc: "2.0" as const, - method: "test", - params: {}, - id: "test-id" - }; - - try { - await transport.send(message); - } catch { - // Expected to fail due to mock setup, we're just testing auth call - } + // Test finishAuth method which calls auth directly + await transport.finishAuth("test-auth-code"); + // Verify auth was called correctly without the deprecated parameter expect(authSpy).toHaveBeenCalledWith( mockAuthProvider, expect.objectContaining({ - initialAccessToken: "test-initial-token" + serverUrl: resourceBaseUrl, + authorizationCode: "test-auth-code" + }) + ); + + // Verify no initialAccessToken parameter + expect(authSpy).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + initialAccessToken: expect.anything() }) ); - // Restore fetch and spy - global.fetch = originalFetch; authSpy.mockRestore(); }); }); diff --git a/src/client/sse.ts b/src/client/sse.ts index 98484bfec..568a51592 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -52,16 +52,6 @@ export type SSEClientTransportOptions = { * Custom fetch implementation used for all network requests. */ fetch?: FetchLike; - - /** - * Initial access token for OAuth 2.0 Dynamic Client Registration (RFC 7591). - * This token is used to authorize the client registration request with authorization servers - * that require pre-authorization for dynamic client registration. - * - * If not provided, the system will fall back to the provider's `initialAccessToken()` method - * and then to the `OAUTH_INITIAL_ACCESS_TOKEN` environment variable. - */ - initialAccessToken?: string; }; /** @@ -79,7 +69,6 @@ export class SSEClientTransport implements Transport { private _authProvider?: OAuthClientProvider; private _fetch?: FetchLike; private _protocolVersion?: string; - private _initialAccessToken?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -95,7 +84,6 @@ export class SSEClientTransport implements Transport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; - this._initialAccessToken = opts?.initialAccessToken; } private async _authThenStart(): Promise { @@ -105,7 +93,7 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, initialAccessToken: this._initialAccessToken }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -230,7 +218,7 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, initialAccessToken: this._initialAccessToken }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -264,7 +252,7 @@ const response = await (this._fetch ?? fetch)(this._endpoint, init); this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, initialAccessToken: this._initialAccessToken }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index baeb955be..1db3db484 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -856,42 +856,70 @@ describe("StreamableHTTPClientTransport", () => { expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens'); }); - describe("initialAccessToken support", () => { - it("stores initialAccessToken from constructor options", () => { + describe("DCR registration access token support via provider", () => { + it("works with provider that implements dcrRegistrationAccessToken method", async () => { + // Create a mock provider with DCR token method + const providerWithDcr = { + ...mockAuthProvider, + dcrRegistrationAccessToken: jest.fn().mockResolvedValue("provider-dcr-token") + }; + + // Create a spy on the auth module + const authModule = await import("./auth.js"); + const authSpy = jest.spyOn(authModule, "auth").mockResolvedValue("REDIRECT"); + const transport = new StreamableHTTPClientTransport( new URL("http://localhost:1234/mcp"), - { initialAccessToken: "test-initial-token" } + { authProvider: providerWithDcr } ); - - // Access private property for testing - const transportInstance = transport as unknown as { _initialAccessToken?: string }; - expect(transportInstance._initialAccessToken).toBe("test-initial-token"); - }); - it("works without initialAccessToken (backward compatibility)", async () => { - const transport = new StreamableHTTPClientTransport( - new URL("http://localhost:1234/mcp"), - { authProvider: mockAuthProvider } + // Mock fetch to trigger auth flow on send (401 response) + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + headers: new Headers(), + }); + + const message = { + jsonrpc: "2.0" as const, + method: "test", + params: {}, + id: "test-id" + }; + + try { + await transport.send(message); + } catch { + // Expected to fail due to mock setup, we're just testing auth call + } + + // Verify auth was called with the provider (no initialAccessToken parameter) + expect(authSpy).toHaveBeenCalledWith( + providerWithDcr, + expect.objectContaining({ + serverUrl: new URL("http://localhost:1234/mcp") + }) ); - - const transportInstance = transport as unknown as { _initialAccessToken?: string }; - expect(transportInstance._initialAccessToken).toBeUndefined(); - // Should not throw when no initial access token provided - expect(() => transport).not.toThrow(); + // Verify the initialAccessToken parameter is NOT included + expect(authSpy).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + initialAccessToken: expect.anything() + }) + ); + + authSpy.mockRestore(); }); - it("includes initialAccessToken in auth calls", async () => { - // Create a spy on the auth module + it("works with provider without dcrRegistrationAccessToken method", async () => { + // Use the regular mock provider (no DCR method) const authModule = await import("./auth.js"); const authSpy = jest.spyOn(authModule, "auth").mockResolvedValue("REDIRECT"); const transport = new StreamableHTTPClientTransport( new URL("http://localhost:1234/mcp"), - { - authProvider: mockAuthProvider, - initialAccessToken: "test-initial-token" - } + { authProvider: mockAuthProvider } ); // Mock fetch to trigger auth flow on send (401 response) @@ -914,10 +942,55 @@ describe("StreamableHTTPClientTransport", () => { // Expected to fail due to mock setup, we're just testing auth call } + // Verify auth was called correctly without initialAccessToken parameter expect(authSpy).toHaveBeenCalledWith( mockAuthProvider, expect.objectContaining({ - initialAccessToken: "test-initial-token" + serverUrl: new URL("http://localhost:1234/mcp") + }) + ); + + // Verify no initialAccessToken parameter + expect(authSpy).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + initialAccessToken: expect.anything() + }) + ); + + authSpy.mockRestore(); + }); + + it("handles DCR token during finishAuth flow", async () => { + const providerWithDcr = { + ...mockAuthProvider, + dcrRegistrationAccessToken: jest.fn().mockResolvedValue("provider-dcr-token") + }; + + const authModule = await import("./auth.js"); + const authSpy = jest.spyOn(authModule, "auth").mockResolvedValue("AUTHORIZED"); + + const transport = new StreamableHTTPClientTransport( + new URL("http://localhost:1234/mcp"), + { authProvider: providerWithDcr } + ); + + // Test the finishAuth flow which also calls auth() + await transport.finishAuth("test-auth-code"); + + expect(authSpy).toHaveBeenCalledWith( + providerWithDcr, + expect.objectContaining({ + serverUrl: new URL("http://localhost:1234/mcp"), + authorizationCode: "test-auth-code" + }) + ); + + // Verify no initialAccessToken parameter + expect(authSpy).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + initialAccessToken: expect.anything() }) ); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index a79037224..b0894fce1 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -114,14 +114,6 @@ export type StreamableHTTPClientTransportOptions = { * When not provided and connecting to a server that supports session IDs, the server will generate a new session ID. */ sessionId?: string; - - /** - * Initial access token for OAuth 2.0 Dynamic Client Registration (RFC 7591). - * This token is used to authorize the client registration request with authorization servers that require pre-authorization for dynamic client registration. - * - * If not provided, the system will fall back to the provider's `initialAccessToken()` method and then to the `OAUTH_INITIAL_ACCESS_TOKEN` environment variable. - */ - initialAccessToken?: string; }; /** @@ -139,7 +131,6 @@ export class StreamableHTTPClientTransport implements Transport { private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; private _protocolVersion?: string; - private _initialAccessToken?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -156,7 +147,6 @@ export class StreamableHTTPClientTransport implements Transport { this._fetch = opts?.fetch; this._sessionId = opts?.sessionId; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; - this._initialAccessToken = opts?.initialAccessToken; } private async _authThenStart(): Promise { @@ -166,7 +156,7 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, initialAccessToken: this._initialAccessToken }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -402,7 +392,7 @@ const response = await (this._fetch ?? fetch)(this._url, { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, initialAccessToken: this._initialAccessToken }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -450,7 +440,7 @@ const response = await (this._fetch ?? fetch)(this._url, init); this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, initialAccessToken: this._initialAccessToken }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/examples/README.md b/src/examples/README.md index f5523fa9a..cf896c110 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -39,9 +39,9 @@ Example client with OAuth: npx tsx src/examples/client/simpleOAuthClient.js ``` -The OAuth client example supports DCR registration access tokens (called "initial access token" in RFC 7591) for dynamic client registration. You can provide the token via: +The OAuth client example supports DCR registration access tokens (called "initial access token" in RFC 7591) for dynamic client registration. The SDK automatically checks for the `DCR_REGISTRATION_ACCESS_TOKEN` environment variable: - Environment variable: `export DCR_REGISTRATION_ACCESS_TOKEN="your-token"` -- Transport configuration (see source code for examples) +- Custom provider implementation (see source code for examples) ### Backwards Compatible Client From 914e9135159f3c01b2124c50924dd6312b920498 Mon Sep 17 00:00:00 2001 From: Andor Markus Date: Thu, 24 Jul 2025 19:19:09 +0200 Subject: [PATCH 6/6] feat: add DCR registration access token support (RFC 7591 initial access token) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive support for DCR registration access tokens (called "initial access token" in RFC 7591) for OAuth 2.0 Dynamic Client Registration. This enables pre-authorization for client registration with authorization servers that require it. Features: - Optional `dcrRegistrationAccessToken()` method in OAuthClientProvider interface - Clean 2-level fallback: provider method → DCR_REGISTRATION_ACCESS_TOKEN env var → none - Automatic integration with existing registerClient() function - Comprehensive test coverage with all fallback scenarios - Enhanced examples demonstrating basic and advanced DCR strategies Examples: - Updated simpleOAuthClient.ts with DCR token demonstration - New advancedDcrOAuthClient.ts showing production-ready strategies - Support for CLI arguments, environment variables, and secure storage - Complete documentation with RFC 7591 terminology mapping Implementation follows feedback to: - Use descriptive naming (DCR_REGISTRATION_ACCESS_TOKEN vs generic "initial access token") - Implement clean 2-level fallback without excessive complexity - Place token handling specifically in auth.ts registerClient function - Maintain backward compatibility Resolves: Support for RFC 7591 Dynamic Client Registration initial access tokens" --- README.md | 6 +- src/examples/README.md | 64 +++++++++++++++++++-- src/examples/client/simpleOAuthClient.ts | 72 +++++++++++++++++++++++- 3 files changed, 135 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 96c3e5cfd..fdc02a82e 100644 --- a/README.md +++ b/README.md @@ -1238,7 +1238,7 @@ The SDK automatically checks for a `DCR_REGISTRATION_ACCESS_TOKEN` environment v ##### Method 1: Environment Variable (Default) ```bash -export DCR_REGISTRATION_ACCESS_TOKEN="your-dcr-registration-access-token" +export DCR_REGISTRATION_ACCESS_TOKEN="your-initial-access-token" ``` ##### Method 2: Custom Provider Method @@ -1287,6 +1287,10 @@ try { } ``` +For complete working examples of OAuth with DCR token support, see: +- [`src/examples/client/simpleOAuthClient.ts`](src/examples/client/simpleOAuthClient.ts) - Basic OAuth client with DCR support +- [`src/examples/client/advancedDcrOAuthClient.ts`](src/examples/client/advancedDcrOAuthClient.ts) - Advanced DCR strategies for production + ### Proxy Authorization Requests Upstream You can proxy OAuth requests to an external authorization provider: diff --git a/src/examples/README.md b/src/examples/README.md index cf896c110..3d12ff271 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -36,12 +36,68 @@ npx tsx src/examples/client/simpleStreamableHttp.ts Example client with OAuth: ```bash -npx tsx src/examples/client/simpleOAuthClient.js +npx tsx src/examples/client/simpleOAuthClient.ts ``` -The OAuth client example supports DCR registration access tokens (called "initial access token" in RFC 7591) for dynamic client registration. The SDK automatically checks for the `DCR_REGISTRATION_ACCESS_TOKEN` environment variable: -- Environment variable: `export DCR_REGISTRATION_ACCESS_TOKEN="your-token"` -- Custom provider implementation (see source code for examples) +#### OAuth DCR Registration Access Token Support (RFC 7591) + +The OAuth client example demonstrates comprehensive support for DCR (Dynamic Client Registration) registration access tokens (called "initial access token" in RFC 7591) for authorization servers that require pre-authorization. The example shows multiple ways to provide DCR tokens: + +##### Method 1: Environment Variable (Automatic SDK Fallback) +```bash +export DCR_REGISTRATION_ACCESS_TOKEN="your-initial-access-token" +npx tsx src/examples/client/simpleOAuthClient.ts +``` + +##### Method 2: Command Line Argument +```bash +npx tsx src/examples/client/simpleOAuthClient.ts --dcr-token "your-initial-access-token" +``` + +##### Method 3: Custom Provider Implementation +The example shows how to implement custom DCR token logic in your OAuth provider: + +```typescript +class MyOAuthProvider implements OAuthClientProvider { + // ... other methods ... + + async dcrRegistrationAccessToken(): Promise { + // Custom fallback logic: + // 1. Check explicit parameter + // 2. Check command line arguments + // 3. Check environment variables + // 4. Check secure storage (keychain, vault, etc.) + return this.getTokenFromCustomSource(); + } +} +``` + +The SDK implements a clean 2-level fallback: +1. **Provider method**: Custom `dcrRegistrationAccessToken()` implementation (if provided) +2. **Environment variable**: `DCR_REGISTRATION_ACCESS_TOKEN` (automatic fallback for RFC 7591 "initial access token") +3. **None**: Proceed without pre-authorization (for servers that don't require it) + +#### Advanced DCR Strategies Example + +For production environments requiring sophisticated DCR token management (called "initial access token" in RFC 7591), see the advanced example: + +```bash +# Demonstrate all DCR strategies +npx tsx src/examples/client/advancedDcrOAuthClient.ts + +# Demo strategies only (no connection attempt) +npx tsx src/examples/client/advancedDcrOAuthClient.ts --demo-only + +# Attempt real connection with DCR support +npx tsx src/examples/client/advancedDcrOAuthClient.ts --connect --dcr-token "your-initial-access-token" +``` + +This example demonstrates: +- **Token exchange**: Dynamic DCR initial access token acquisition via client credentials +- **Secure storage**: Integration with OS keychain, HashiCorp Vault, AWS Secrets Manager +- **Multiple environment variables**: Support for various DCR token env var names (RFC 7591 "initial access token") +- **Fallback strategies**: Comprehensive 6-level fallback approach +- **Production patterns**: Real-world deployment scenarios and security practices ### Backwards Compatible Client diff --git a/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts index 4531f4c2a..0580e8b8a 100644 --- a/src/examples/client/simpleOAuthClient.ts +++ b/src/examples/client/simpleOAuthClient.ts @@ -32,7 +32,8 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider { constructor( private readonly _redirectUrl: string | URL, private readonly _clientMetadata: OAuthClientMetadata, - onRedirect?: (url: URL) => void + onRedirect?: (url: URL) => void, + private readonly _dcrToken?: string ) { this._onRedirect = onRedirect || ((url) => { console.log(`Redirect to: ${url.toString()}`); @@ -79,6 +80,55 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider { } return this._codeVerifier; } + + /** + * DCR registration access token provider (RFC 7591) + * Provides "initial access token" as defined in RFC 7591 for Dynamic Client Registration + * This demonstrates custom DCR token logic with fallback strategies + */ + async dcrRegistrationAccessToken(): Promise { + // Strategy 1: Use explicit token provided to constructor + if (this._dcrToken) { + console.log('🔑 Using DCR token from constructor parameter'); + return this._dcrToken; + } + + // Strategy 2: Check for command line argument + const args = process.argv; + const dcrArgIndex = args.findIndex(arg => arg === '--dcr-token'); + if (dcrArgIndex !== -1 && args[dcrArgIndex + 1]) { + console.log('🔑 Using DCR token from command line argument'); + return args[dcrArgIndex + 1]; + } + + // Strategy 3: Check environment variable (this is also the SDK's automatic fallback) + if (process.env.DCR_REGISTRATION_ACCESS_TOKEN) { + console.log('🔑 Using DCR token from DCR_REGISTRATION_ACCESS_TOKEN environment variable'); + console.log(' (RFC 7591 "initial access token")'); + return process.env.DCR_REGISTRATION_ACCESS_TOKEN; + } + + // Strategy 4: Could load from secure storage (e.g., keychain, vault) + // const tokenFromStorage = await this.loadDcrTokenFromSecureStorage(); + // if (tokenFromStorage) { + // console.log('🔑 Using DCR token from secure storage'); + // return tokenFromStorage; + // } + + console.log('â„šī¸ No DCR registration access token available - proceeding without pre-authorization'); + return undefined; + } + + // Example method for secure storage (not implemented in this demo) + // private async loadDcrTokenFromSecureStorage(): Promise { + // // In production, you might load from: + // // - OS keychain/keyring + // // - HashiCorp Vault + // // - AWS Secrets Manager + // // - Azure Key Vault + // // - etc. + // return undefined; + // } } /** * Interactive MCP client with OAuth authentication @@ -224,6 +274,23 @@ class InteractiveOAuthClient { }; console.log('🔐 Creating OAuth provider...'); + + // Check for DCR token from command line (--dcr-token ) + const args = process.argv; + const dcrArgIndex = args.findIndex(arg => arg === '--dcr-token'); + const explicitDcrToken = dcrArgIndex !== -1 && args[dcrArgIndex + 1] ? args[dcrArgIndex + 1] : undefined; + + if (explicitDcrToken) { + console.log('🔑 DCR registration access token provided via command line'); + console.log(' This will be used for pre-authorized dynamic client registration (RFC 7591)'); + } else if (process.env.DCR_REGISTRATION_ACCESS_TOKEN) { + console.log('🔑 DCR registration access token available via environment variable'); + console.log(' This will be used for pre-authorized dynamic client registration (RFC 7591)'); + } else { + console.log('â„šī¸ No DCR registration access token provided (proceeding without pre-authorization)'); + console.log(' Client registration will proceed normally (if the auth server supports it)'); + } + const oauthProvider = new InMemoryOAuthClientProvider( CALLBACK_URL, clientMetadata, @@ -231,7 +298,8 @@ class InteractiveOAuthClient { console.log(`📌 OAuth redirect handler called - opening browser`); console.log(`Opening browser to: ${redirectUrl.toString()}`); this.openBrowser(redirectUrl.toString()); - } + }, + explicitDcrToken // Pass DCR token to provider ); console.log('🔐 OAuth provider created');