diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3f65093d0..911c08bdf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -63,6 +63,16 @@ jobs: - run: npm ci - - run: npm publish --provenance --access public + - name: Determine npm tag + id: npm-tag + run: | + VERSION=$(node -p "require('./package.json').version") + if [[ "$VERSION" == *"-beta"* ]]; then + echo "tag=--tag beta" >> $GITHUB_OUTPUT + else + echo "tag=" >> $GITHUB_OUTPUT + fi + + - run: npm publish --provenance --access public ${{ steps.npm-tag.outputs.tag }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package-lock.json b/package-lock.json index e788506d3..d6791ef06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.22.0", + "version": "1.23.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.22.0", + "version": "1.23.0-beta.0", "license": "MIT", "dependencies": { "ajv": "^8.17.1", @@ -1315,7 +1315,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.11.0", "@typescript-eslint/types": "8.11.0", @@ -1727,7 +1726,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2282,7 +2280,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", @@ -4141,7 +4138,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4197,7 +4193,6 @@ "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -4243,7 +4238,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4445,7 +4439,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4459,7 +4452,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4612,7 +4604,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b103f4a6e..560c4bcb2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.22.0", + "version": "1.23.0-beta.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index aec5b7ff6..0e3a544a2 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -95,6 +95,16 @@ describe('OAuth Authorization', () => { expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope }); }); + + it('returns error when present', async () => { + const mockResponse = { + headers: { + get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer error="insufficient_scope", scope="admin"` : null)) + } + } as unknown as Response; + + expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ error: 'insufficient_scope', scope: 'admin' }); + }); }); describe('discoverOAuthProtectedResourceMetadata', () => { diff --git a/src/client/auth.ts b/src/client/auth.ts index 105d3cad9..536ff6859 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -522,9 +522,9 @@ export async function selectResourceURL( } /** - * Extract resource_metadata and scope from WWW-Authenticate header. + * Extract resource_metadata, scope, and error from WWW-Authenticate header. */ -export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string } { +export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string; error?: string } { const authenticateHeader = res.headers.get('WWW-Authenticate'); if (!authenticateHeader) { return {}; @@ -535,29 +535,51 @@ export function extractWWWAuthenticateParams(res: Response): { resourceMetadataU return {}; } - const resourceMetadataRegex = /resource_metadata="([^"]*)"/; - const resourceMetadataMatch = resourceMetadataRegex.exec(authenticateHeader); - - const scopeRegex = /scope="([^"]*)"/; - const scopeMatch = scopeRegex.exec(authenticateHeader); + const resourceMetadataMatch = extractFieldFromWwwAuth(res, 'resource_metadata') || undefined; let resourceMetadataUrl: URL | undefined; if (resourceMetadataMatch) { try { - resourceMetadataUrl = new URL(resourceMetadataMatch[1]); + resourceMetadataUrl = new URL(resourceMetadataMatch); } catch { // Ignore invalid URL } } - const scope = scopeMatch?.[1] || undefined; + const scope = extractFieldFromWwwAuth(res, 'scope') || undefined; + const error = extractFieldFromWwwAuth(res, 'error') || undefined; return { resourceMetadataUrl, - scope + scope, + error }; } +/** + * Extracts a specific field's value from the WWW-Authenticate header string. + * + * @param response The HTTP response object containing the headers. + * @param fieldName The name of the field to extract (e.g., "realm", "nonce"). + * @returns The field value + */ +function extractFieldFromWwwAuth(response: Response, fieldName: string): string | null { + const wwwAuthHeader = response.headers.get('WWW-Authenticate'); + if (!wwwAuthHeader) { + return null; + } + + const pattern = new RegExp(`${fieldName}=(?:"([^"]+)"|([^\\s,]+))`); + const match = wwwAuthHeader.match(pattern); + + if (match) { + // Pattern matches: field_name="value" or field_name=value (unquoted) + return match[1] || match[2]; + } + + return null; +} + /** * Extract resource_metadata from response header. * @deprecated Use `extractWWWAuthenticateParams` instead. diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 12524fbcd..a4f582cfc 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -593,6 +593,98 @@ describe('StreamableHTTPClientTransport', () => { expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); }); + it('attempts upscoping on 403 with WWW-Authenticate header', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + const fetchMock = global.fetch as Mock; + fetchMock + // First call: returns 403 with insufficient_scope + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ + 'WWW-Authenticate': + 'Bearer error="insufficient_scope", scope="new_scope", resource_metadata="http://example.com/resource"' + }), + text: () => Promise.resolve('Insufficient scope') + }) + // Second call: successful after upscoping + .mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + // Spy on the imported auth function and mock successful authorization + const authModule = await import('./auth.js'); + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.send(message); + + // Verify fetch was called twice + expect(fetchMock).toHaveBeenCalledTimes(2); + + // Verify auth was called with the new scope + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + scope: 'new_scope', + resourceMetadataUrl: new URL('http://example.com/resource') + }) + ); + + authSpy.mockRestore(); + }); + + it('prevents infinite upscoping on repeated 403', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + // Mock fetch calls to always return 403 with insufficient_scope + const fetchMock = global.fetch as Mock; + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ + 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="new_scope"' + }), + text: () => Promise.resolve('Insufficient scope') + }); + + // Spy on the imported auth function and mock successful authorization + const authModule = await import('./auth.js'); + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + // First send: should trigger upscoping + await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping'); + + expect(fetchMock).toHaveBeenCalledTimes(2); // Initial call + one retry after auth + expect(authSpy).toHaveBeenCalledTimes(1); // Auth called once + + // Second send: should fail immediately without re-calling auth + fetchMock.mockClear(); + authSpy.mockClear(); + await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping'); + + expect(fetchMock).toHaveBeenCalledTimes(1); // Only one fetch call + expect(authSpy).not.toHaveBeenCalled(); // Auth not called again + + authSpy.mockRestore(); + }); + describe('Reconnection Logic', () => { let transport: StreamableHTTPClientTransport; diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 508f8cef9..3ca50b954 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -134,6 +134,7 @@ export class StreamableHTTPClientTransport implements Transport { private _reconnectionOptions: StreamableHTTPReconnectionOptions; private _protocolVersion?: string; private _hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401 + private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping. onclose?: () => void; onerror?: (error: Error) => void; @@ -452,12 +453,49 @@ export class StreamableHTTPClientTransport implements Transport { return this.send(message); } + if (response.status === 403 && this._authProvider) { + const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); + + if (error === 'insufficient_scope') { + const wwwAuthHeader = response.headers.get('WWW-Authenticate'); + + // Check if we've already tried upscoping with this header to prevent infinite loops. + if (this._lastUpscopingHeader === wwwAuthHeader) { + throw new StreamableHTTPError(403, 'Server returned 403 after trying upscoping'); + } + + if (scope) { + this._scope = scope; + } + + if (resourceMetadataUrl) { + this._resourceMetadataUrl = resourceMetadataUrl; + } + + // Mark that upscoping was tried. + this._lastUpscopingHeader = wwwAuthHeader ?? undefined; + const result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, + fetchFn: this._fetch + }); + + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } + + return this.send(message); + } + } + const text = await response.text().catch(() => null); throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`); } // Reset auth loop flag on successful response this._hasCompletedAuthFlow = false; + this._lastUpscopingHeader = undefined; // If the response is 202 Accepted, there's no body to process if (response.status === 202) { diff --git a/src/examples/client/elicitationUrlExample.ts b/src/examples/client/elicitationUrlExample.ts index b3a962b64..b57927e3f 100644 --- a/src/examples/client/elicitationUrlExample.ts +++ b/src/examples/client/elicitationUrlExample.ts @@ -477,6 +477,42 @@ async function waitForOAuthCallback(): Promise { }); } +/** + * Attempts to connect to the MCP server with OAuth authentication. + * Handles OAuth flow recursively if authorization is required. + */ +async function attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise { + console.log('🚢 Creating transport with OAuth provider...'); + const baseUrl = new URL(serverUrl); + transport = new StreamableHTTPClientTransport(baseUrl, { + sessionId: sessionId, + authProvider: oauthProvider + }); + console.log('🚢 Transport created'); + + try { + console.log('🔌 Attempting connection (this will trigger OAuth redirect if needed)...'); + await client!.connect(transport); + sessionId = transport.sessionId; + console.log('Transport created with session ID:', sessionId); + console.log('✅ Connected successfully'); + } catch (error) { + if (error instanceof UnauthorizedError) { + console.log('🔐 OAuth required - waiting for authorization...'); + const callbackPromise = waitForOAuthCallback(); + const authCode = await callbackPromise; + await transport.finishAuth(authCode); + console.log('🔐 Authorization code received:', authCode); + console.log('🔌 Reconnecting with authenticated transport...'); + // Recursively retry connection after OAuth completion + await attemptConnection(oauthProvider); + } else { + console.error('❌ Connection failed with non-auth error:', error); + throw error; + } + } +} + async function connect(url?: string): Promise { if (client) { console.log('Already connected. Disconnect first.'); @@ -487,7 +523,10 @@ async function connect(url?: string): Promise { serverUrl = url; } + console.log(`🔗 Attempting to connect to ${serverUrl}...`); + // Create a new client with elicitation capability + console.log('👤 Creating MCP client...'); client = new Client( { name: 'example-client', @@ -503,19 +542,7 @@ async function connect(url?: string): Promise { } } ); - if (!transport) { - // Only create a new transport if one doesn't exist - transport = new StreamableHTTPClientTransport(new URL(serverUrl), { - sessionId: sessionId, - authProvider: oauthProvider, - requestInit: { - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream' - } - } - }); - } + console.log('👤 Client created'); // Set up elicitation request handler with proper validation client.setRequestHandler(ElicitRequestSchema, elicitationRequestHandler); @@ -536,42 +563,20 @@ async function connect(url?: string): Promise { }); try { - console.log(`Connecting to ${serverUrl}...`); - // Connect the client - await client.connect(transport); - sessionId = transport.sessionId; - console.log('Transport created with session ID:', sessionId); + console.log('🔐 Starting OAuth flow...'); + await attemptConnection(oauthProvider!); console.log('Connected to MCP server'); + + // Set up error handler after connection is established so we don't double log errors + client.onerror = error => { + console.error('\x1b[31mClient error:', error, '\x1b[0m'); + }; } catch (error) { - if (error instanceof UnauthorizedError) { - console.log('OAuth required - waiting for authorization...'); - const callbackPromise = waitForOAuthCallback(); - const authCode = await callbackPromise; - await transport.finishAuth(authCode); - console.log('🔐 Authorization code received:', authCode); - console.log('🔌 Reconnecting with authenticated transport...'); - transport = new StreamableHTTPClientTransport(new URL(serverUrl), { - sessionId: sessionId, - authProvider: oauthProvider, - requestInit: { - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream' - } - } - }); - await client.connect(transport); - } else { - console.error('Failed to connect:', error); - client = null; - transport = null; - return; - } + console.error('Failed to connect:', error); + client = null; + transport = null; + return; } - // Set up error handler after connection is established so we don't double log errors - client.onerror = error => { - console.error('\x1b[31mClient error:', error, '\x1b[0m'); - }; } async function disconnect(): Promise { diff --git a/src/server/index.ts b/src/server/index.ts index 8de1a3cc4..8ec838e51 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -345,11 +345,14 @@ export class Server< async elicitInput(params: ElicitRequestURLParams, options?: RequestOptions): Promise; /** * Creates an elicitation request for the given parameters. + * @deprecated Use the overloads with explicit `mode: 'form' | 'url'` instead. * @param params The parameters for the form elicitation request (legacy signature without mode). * @param options Optional request options. * @returns The result of the elicitation request. */ async elicitInput(params: LegacyElicitRequestFormParams, options?: RequestOptions): Promise; + + // Implementation (not visible to callers) async elicitInput( params: LegacyElicitRequestFormParams | ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions