From 5823deb7c8bc78824036b42148f68ec80e8cfcb8 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 24 Jun 2026 16:27:01 -0500 Subject: [PATCH 1/3] fix(mcp): isolate OAuth request headers --- packages/opencode/src/cli/cmd/mcp.ts | 5 +- packages/opencode/src/mcp/index.ts | 5 +- packages/opencode/test/mcp/headers.test.ts | 28 ++- .../test/mcp/oauth-header-isolation.test.ts | 221 ++++++++++++++++++ .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 172 +++++++++++--- 5 files changed, 396 insertions(+), 35 deletions(-) create mode 100644 packages/opencode/test/mcp/oauth-header-isolation.test.ts diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 06cb77bce135..ac82b877046f 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -769,7 +769,10 @@ export const McpDebugCommand = effectCmd({ prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) } - if (response.status === 401) { + if ( + response.status === 401 && + !Object.keys(serverConfig.headers ?? {}).some((key) => key.toLowerCase() === "authorization") + ) { prompts.log.warn("Server returned 401 Unauthorized") // Try to discover OAuth metadata diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index db673244b3d7..56b7861e1345 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -226,7 +226,8 @@ export const layer = Layer.effect( key: string, mcp: ConfigMCPV1.Info & { type: "remote" }, ) { - const oauthDisabled = mcp.oauth === false + const oauthDisabled = + mcp.oauth === false || Object.keys(mcp.headers ?? {}).some((key) => key.toLowerCase() === "authorization") const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined const url = remoteURL(mcp.url) if (!url) { @@ -797,6 +798,8 @@ export const layer = Layer.effect( const mcpConfig = yield* requireMcpConfig(mcpName) if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) + if (Object.keys(mcpConfig.headers ?? {}).some((key) => key.toLowerCase() === "authorization")) + throw new Error(`MCP server ${mcpName} uses static Authorization`) const url = remoteURL(mcpConfig.url) if (!url) throw new Error(`Invalid MCP URL for "${mcpName}"`) diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index c51ed00d32f6..737ae43cfaf0 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -49,7 +49,7 @@ const { MCP } = await import("../../src/mcp/index") const it = testEffect(MCP.defaultLayer) describe("mcp.headers", () => { - it.instance("headers are passed to transports when oauth is enabled (default)", () => + it.instance("static authorization is passed to transports without oauth", () => Effect.gen(function* () { const mcp = yield* MCP.Service yield* mcp @@ -72,8 +72,8 @@ describe("mcp.headers", () => { Authorization: "Bearer test-token", "X-Custom-Header": "custom-value", }) - // OAuth should be enabled by default, so authProvider should exist - expect(call.options.authProvider).toBeDefined() + // Static Authorization and OAuth are mutually exclusive. + expect(call.options.authProvider).toBeUndefined() } }), ) @@ -105,6 +105,28 @@ describe("mcp.headers", () => { }), ) + it.instance("case-insensitive authorization disables oauth", () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + yield* mcp + .add("test-server-lowercase-auth", { + type: "remote", + url: "https://example.com/mcp", + headers: { authorization: "Bearer test-token", "X-Custom-Header": "custom-value" }, + }) + .pipe(Effect.catch(() => Effect.void)) + + expect(transportCalls.length).toBeGreaterThanOrEqual(1) + for (const call of transportCalls) { + expect(call.options.requestInit?.headers).toEqual({ + authorization: "Bearer test-token", + "X-Custom-Header": "custom-value", + }) + expect(call.options.authProvider).toBeUndefined() + } + }), + ) + it.instance("no requestInit when headers are not provided", () => Effect.gen(function* () { const mcp = yield* MCP.Service diff --git a/packages/opencode/test/mcp/oauth-header-isolation.test.ts b/packages/opencode/test/mcp/oauth-header-isolation.test.ts new file mode 100644 index 000000000000..5516241b5b28 --- /dev/null +++ b/packages/opencode/test/mcp/oauth-header-isolation.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "bun:test" +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js" + +const canary = "resource-secret" +const staticAuthorization = "Bearer static-resource-token" +const basicAuthorization = `Basic ${btoa("client:secret")}` + +describe("MCP OAuth header isolation", () => { + test("keeps resource headers out of discovery, registration, and token exchange", async () => { + using token = serve(() => Response.json({ access_token: "access", token_type: "Bearer", refresh_token: "refresh" })) + using registration = serve(() => + Response.json({ + client_id: "client", + client_secret: "secret", + token_endpoint_auth_method: "client_secret_basic", + redirect_uris: ["http://127.0.0.1/callback"], + client_name: "OpenCode test", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }), + ) + using authorizationServer = serve(() => + Response.json({ + issuer: authorizationServer.origin, + authorization_endpoint: `${authorizationServer.origin}/authorize`, + token_endpoint: `${token.origin}/token`, + registration_endpoint: `${registration.origin}/register`, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + }), + ) + using metadata = serve(() => + Response.json({ + resource: `${resource.origin}/mcp`, + authorization_servers: [authorizationServer.origin], + }), + ) + using resource = serve( + () => + new Response(null, { + status: 401, + headers: { "WWW-Authenticate": `Bearer resource_metadata="${metadata.origin}/prm"` }, + }), + ) + const provider = createProvider() + const transport = new StreamableHTTPClientTransport(new URL(`${resource.origin}/mcp`), { + authProvider: provider, + requestInit: { headers: { "X-Resource-Canary": canary, aUtHoRiZaTiOn: staticAuthorization } }, + }) + + await transport.start() + await expect(transport.send(request())).rejects.toBeInstanceOf(UnauthorizedError) + await transport.finishAuth("authorization-code") + await transport.close() + + expectResourceHeaders(resource.requests) + expectNoResourceHeaders(metadata.requests) + expectNoResourceHeaders(authorizationServer.requests) + expectNoResourceHeaders(registration.requests) + expectNoResourceHeaders(token.requests) + expect(registration.requests).toHaveLength(1) + expect(token.requests).toHaveLength(1) + expectAuthorization(token.requests[0], basicAuthorization) + expect(new URLSearchParams(token.requests[0].body).get("grant_type")).toBe("authorization_code") + }) + + test("keeps resource headers out of token refresh", async () => { + using refresh = serve(() => Response.json({ access_token: "fresh-access", token_type: "Bearer" })) + using authorizationServer = serve(() => + Response.json({ + issuer: authorizationServer.origin, + authorization_endpoint: `${authorizationServer.origin}/authorize`, + token_endpoint: `${refresh.origin}/refresh`, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + }), + ) + using metadata = serve(() => + Response.json({ + resource: `${resource.origin}/mcp`, + authorization_servers: [authorizationServer.origin], + }), + ) + using resource = serve((request) => { + if (resource.requests.length === 1) { + return new Response(null, { + status: 401, + headers: { "WWW-Authenticate": `Bearer resource_metadata="${metadata.origin}/prm"` }, + }) + } + return Response.json({ jsonrpc: "2.0", id: 1, result: {} }) + }) + const transport = new StreamableHTTPClientTransport(new URL(`${resource.origin}/mcp`), { + authProvider: createProvider({ + clientInformation: { + client_id: "client", + client_secret: "secret", + token_endpoint_auth_method: "client_secret_basic", + }, + tokens: { access_token: "expired-access", token_type: "Bearer", refresh_token: "refresh-token" }, + }), + requestInit: { headers: { "X-Resource-Canary": canary, authorization: staticAuthorization } }, + }) + transport.onmessage = () => {} + + await transport.start() + await transport.send(request()) + await transport.close() + + expect(resource.requests).toHaveLength(2) + expectResourceHeaders(resource.requests, ["Bearer expired-access", "Bearer fresh-access"]) + expectNoResourceHeaders(metadata.requests) + expectNoResourceHeaders(authorizationServer.requests) + expectNoResourceHeaders(refresh.requests) + expect(refresh.requests).toHaveLength(1) + expectAuthorization(refresh.requests[0], basicAuthorization) + expect(new URLSearchParams(refresh.requests[0].body).get("grant_type")).toBe("refresh_token") + }) +}) + +function createProvider(initial?: { + clientInformation?: OAuthClientInformationMixed + tokens?: OAuthTokens +}): OAuthClientProvider { + let clientInformation = initial?.clientInformation + let tokens = initial?.tokens + let codeVerifier = "" + return { + redirectUrl: "http://127.0.0.1/callback", + clientMetadata: { + redirect_uris: ["http://127.0.0.1/callback"], + client_name: "OpenCode test", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + } satisfies OAuthClientMetadata, + clientInformation: () => clientInformation, + saveClientInformation: (value) => { + clientInformation = value + }, + tokens: () => tokens, + saveTokens: (value) => { + tokens = value + }, + redirectToAuthorization: () => {}, + saveCodeVerifier: (value) => { + codeVerifier = value + }, + codeVerifier: () => codeVerifier, + state: () => "state", + } +} + +function request() { + return { + jsonrpc: "2.0" as const, + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "opencode-test", version: "1" }, + }, + } +} + +function expectResourceHeaders(requests: CapturedRequest[], authorization: string | string[] = staticAuthorization) { + expect(requests.length).toBeGreaterThan(0) + for (const [index, request] of requests.entries()) { + expect(request.headers.get("x-resource-canary")).toBe(canary) + expectAuthorization(request, Array.isArray(authorization) ? authorization[index] : authorization) + } +} + +function expectNoResourceHeaders(requests: CapturedRequest[]) { + expect(requests.length).toBeGreaterThan(0) + for (const request of requests) { + expect(request.headers.get("x-resource-canary")).toBeNull() + expect(request.headers.get("authorization")).not.toBe(staticAuthorization) + } +} + +function expectAuthorization(request: CapturedRequest, value: string) { + expect([...request.headers].filter(([name]) => name === "authorization")).toEqual([["authorization", value]]) +} + +interface CapturedRequest { + url: string + headers: Headers + body: string +} + +function serve(handler: (request: CapturedRequest) => Response | Promise) { + const requests: CapturedRequest[] = [] + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const captured = { + url: request.url, + headers: new Headers(request.headers), + body: await request.text(), + } + requests.push(captured) + return handler(captured) + }, + }) + return { + origin: `http://127.0.0.1:${server.port}`, + requests, + [Symbol.dispose]: () => server.stop(true), + } +} diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index ae25202873b4..241b5f99b704 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -1,5 +1,5 @@ diff --git a/dist/cjs/client/index.d.ts b/dist/cjs/client/index.d.ts -index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644 +index 6f567a193626587a2730b5a49293ca5dfd4181ea..5b7c841c000508e389ce617f559f7c2a5126ca9f 100644 --- a/dist/cjs/client/index.d.ts +++ b/dist/cjs/client/index.d.ts @@ -428,6 +428,8 @@ export declare class Client>; -+ callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; - callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ - [x: string]: unknown; - content: ({ -diff --git a/dist/esm/client/index.d.ts b/dist/esm/client/index.d.ts -index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644 ---- a/dist/esm/client/index.d.ts -+++ b/dist/esm/client/index.d.ts -@@ -428,6 +428,8 @@ export declare class Client>; + callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ [x: string]: unknown; content: ({ diff --git a/dist/cjs/client/index.js b/dist/cjs/client/index.js -index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c101584c84 100644 +index 6ac1da14dc7f6211ae70f7711c124b76098816d8..88e58b90b673cb0f9c60920edec7d2ebb16f112b 100644 --- a/dist/cjs/client/index.js +++ b/dist/cjs/client/index.js @@ -288,41 +288,16 @@ class Client extends protocol_js_1.Protocol { @@ -112,11 +99,67 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 /** * After initialization has completed, this will be populated with the server's reported capabilities. */ +diff --git a/dist/cjs/client/sse.js b/dist/cjs/client/sse.js +index 8ca8dad652e76c299927f836cf622c0be877e1d4..cfbb08438a4e219fcd98d8d4ba8949079f3fcbe5 100644 +--- a/dist/cjs/client/sse.js ++++ b/dist/cjs/client/sse.js +@@ -27,7 +27,8 @@ class SSEClientTransport { + this._requestInit = opts?.requestInit; + this._authProvider = opts?.authProvider; + this._fetch = opts?.fetch; +- this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, opts?.requestInit); ++ const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; ++ this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, oauthRequestInit); + } + async _authThenStart() { + if (!this._authProvider) { +@@ -63,10 +64,11 @@ class SSEClientTransport { + headers['mcp-protocol-version'] = this._protocolVersion; + } + const extraHeaders = (0, transport_js_1.normalizeHeaders)(this._requestInit?.headers); +- return new Headers({ +- ...headers, +- ...extraHeaders +- }); ++ const result = new Headers(extraHeaders); ++ for (const [name, value] of Object.entries(headers)) { ++ result.set(name, value); ++ } ++ return result; + } + _startOrAuth() { + const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch); diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.js -index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644 +index a29a7d3a0f14d9cd800ef5b296485237350c666f..f253f88023c10d44e6cdb74bcf2ffa722619fd0d 100644 --- a/dist/cjs/client/streamableHttp.js +++ b/dist/cjs/client/streamableHttp.js -@@ -290,7 +290,38 @@ class StreamableHTTPClientTransport { +@@ -33,7 +33,8 @@ class StreamableHTTPClientTransport { + this._requestInit = opts?.requestInit; + this._authProvider = opts?.authProvider; + this._fetch = opts?.fetch; +- this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, opts?.requestInit); ++ const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; ++ this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, oauthRequestInit); + this._sessionId = opts?.sessionId; + this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; + } +@@ -74,10 +75,11 @@ class StreamableHTTPClientTransport { + headers['mcp-protocol-version'] = this._protocolVersion; + } + const extraHeaders = (0, transport_js_1.normalizeHeaders)(this._requestInit?.headers); +- return new Headers({ +- ...headers, +- ...extraHeaders +- }); ++ const result = new Headers(extraHeaders); ++ for (const [name, value] of Object.entries(headers)) { ++ result.set(name, value); ++ } ++ return result; + } + async _startOrAuthSse(options) { + const { resumptionToken } = options; +@@ -290,7 +292,38 @@ class StreamableHTTPClientTransport { this.onclose?.(); } async send(message, options) { @@ -155,7 +198,7 @@ index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eed const { resumptionToken, onresumptiontoken } = options || {}; if (resumptionToken) { // If we have at last event ID, we need to reconnect the SSE stream -@@ -298,6 +329,7 @@ class StreamableHTTPClientTransport { +@@ -298,6 +331,7 @@ class StreamableHTTPClientTransport { return; } const headers = await this._commonHeaders(); @@ -163,7 +206,7 @@ index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eed headers.set('content-type', 'application/json'); headers.set('accept', 'application/json, text/event-stream'); const init = { -@@ -310,11 +342,20 @@ class StreamableHTTPClientTransport { +@@ -310,11 +344,20 @@ class StreamableHTTPClientTransport { const response = await (this._fetch ?? fetch)(this._url, init); // Handle session ID received during initialization const sessionId = response.headers.get('mcp-session-id'); @@ -185,7 +228,7 @@ index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eed if (response.status === 401 && this._authProvider) { // Prevent infinite recursion when server returns 401 after successful auth if (this._hasCompletedAuthFlow) { -@@ -335,7 +376,7 @@ class StreamableHTTPClientTransport { +@@ -335,7 +378,7 @@ class StreamableHTTPClientTransport { // Mark that we completed auth flow this._hasCompletedAuthFlow = true; // Purposely _not_ awaited, so we don't call onerror twice @@ -194,7 +237,7 @@ index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eed } if (response.status === 403 && this._authProvider) { const { resourceMetadataUrl, scope, error } = (0, auth_js_1.extractWWWAuthenticateParams)(response); -@@ -362,7 +403,7 @@ class StreamableHTTPClientTransport { +@@ -362,7 +405,7 @@ class StreamableHTTPClientTransport { if (result !== 'AUTHORIZED') { throw new auth_js_1.UnauthorizedError(); } @@ -204,7 +247,7 @@ index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eed } throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); diff --git a/dist/cjs/shared/protocol.js b/dist/cjs/shared/protocol.js -index 3617e787f0ba70447c99501aee7aa67584d89758..4a96d6a0328fa348b96f3869ab7e0bb77538182b 100644 +index 3617e787f0ba70447c99501aee7aa67584d89758..4ee4d158391558fdc1f977f5134b7cacfc45e8c3 100644 --- a/dist/cjs/shared/protocol.js +++ b/dist/cjs/shared/protocol.js @@ -744,7 +744,12 @@ class Protocol { @@ -221,8 +264,21 @@ index 3617e787f0ba70447c99501aee7aa67584d89758..4a96d6a0328fa348b96f3869ab7e0bb7 this._cleanupTimeout(messageId); reject(error); }); +diff --git a/dist/esm/client/index.d.ts b/dist/esm/client/index.d.ts +index 6f567a193626587a2730b5a49293ca5dfd4181ea..5b7c841c000508e389ce617f559f7c2a5126ca9f 100644 +--- a/dist/esm/client/index.d.ts ++++ b/dist/esm/client/index.d.ts +@@ -428,6 +428,8 @@ export declare class Client>; ++ callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; + callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ + [x: string]: unknown; + content: ({ diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js -index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8eb9e4caff 100644 +index 49b12c6cd918c457420fef7ad5528a9443d1a191..339153cb7e9299b7a9bdec0e56e41cedf425fb31 100644 --- a/dist/esm/client/index.js +++ b/dist/esm/client/index.js @@ -284,41 +284,16 @@ export class Client extends Protocol { @@ -309,8 +365,38 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e /** * After initialization has completed, this will be populated with the server's reported capabilities. */ +diff --git a/dist/esm/client/sse.js b/dist/esm/client/sse.js +index 58c474156ba4b33090ac092be6f3284e695a7ffd..ca088b48eeb04a9b65863d9a6dcd3bd11c4a8f71 100644 +--- a/dist/esm/client/sse.js ++++ b/dist/esm/client/sse.js +@@ -23,7 +23,8 @@ export class SSEClientTransport { + this._requestInit = opts?.requestInit; + this._authProvider = opts?.authProvider; + this._fetch = opts?.fetch; +- this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); ++ const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; ++ this._fetchWithInit = createFetchWithInit(opts?.fetch, oauthRequestInit); + } + async _authThenStart() { + if (!this._authProvider) { +@@ -59,10 +60,11 @@ export class SSEClientTransport { + headers['mcp-protocol-version'] = this._protocolVersion; + } + const extraHeaders = normalizeHeaders(this._requestInit?.headers); +- return new Headers({ +- ...headers, +- ...extraHeaders +- }); ++ const result = new Headers(extraHeaders); ++ for (const [name, value] of Object.entries(headers)) { ++ result.set(name, value); ++ } ++ return result; + } + _startOrAuth() { + const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch); diff --git a/dist/esm/client/streamableHttp.js b/dist/esm/client/streamableHttp.js -index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da884fcc627a 100644 +index 624172aa24ae255a67c083f9c19053343e4a0581..d833dce32e6dc90a5b72dee03e70acd0676034da 100644 --- a/dist/esm/client/streamableHttp.js +++ b/dist/esm/client/streamableHttp.js @@ -1,5 +1,5 @@ @@ -320,7 +406,33 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88 import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; // Default reconnection options for StreamableHTTP connections -@@ -286,7 +286,38 @@ export class StreamableHTTPClientTransport { +@@ -29,7 +29,8 @@ export class StreamableHTTPClientTransport { + this._requestInit = opts?.requestInit; + this._authProvider = opts?.authProvider; + this._fetch = opts?.fetch; +- this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); ++ const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; ++ this._fetchWithInit = createFetchWithInit(opts?.fetch, oauthRequestInit); + this._sessionId = opts?.sessionId; + this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; + } +@@ -70,10 +71,11 @@ export class StreamableHTTPClientTransport { + headers['mcp-protocol-version'] = this._protocolVersion; + } + const extraHeaders = normalizeHeaders(this._requestInit?.headers); +- return new Headers({ +- ...headers, +- ...extraHeaders +- }); ++ const result = new Headers(extraHeaders); ++ for (const [name, value] of Object.entries(headers)) { ++ result.set(name, value); ++ } ++ return result; + } + async _startOrAuthSse(options) { + const { resumptionToken } = options; +@@ -286,7 +288,38 @@ export class StreamableHTTPClientTransport { this.onclose?.(); } async send(message, options) { @@ -359,7 +471,7 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88 const { resumptionToken, onresumptiontoken } = options || {}; if (resumptionToken) { // If we have at last event ID, we need to reconnect the SSE stream -@@ -294,6 +325,7 @@ export class StreamableHTTPClientTransport { +@@ -294,6 +327,7 @@ export class StreamableHTTPClientTransport { return; } const headers = await this._commonHeaders(); @@ -367,7 +479,7 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88 headers.set('content-type', 'application/json'); headers.set('accept', 'application/json, text/event-stream'); const init = { -@@ -306,11 +338,20 @@ export class StreamableHTTPClientTransport { +@@ -306,11 +340,20 @@ export class StreamableHTTPClientTransport { const response = await (this._fetch ?? fetch)(this._url, init); // Handle session ID received during initialization const sessionId = response.headers.get('mcp-session-id'); @@ -389,7 +501,7 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88 if (response.status === 401 && this._authProvider) { // Prevent infinite recursion when server returns 401 after successful auth if (this._hasCompletedAuthFlow) { -@@ -331,7 +372,7 @@ export class StreamableHTTPClientTransport { +@@ -331,7 +374,7 @@ export class StreamableHTTPClientTransport { // Mark that we completed auth flow this._hasCompletedAuthFlow = true; // Purposely _not_ awaited, so we don't call onerror twice @@ -398,7 +510,7 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88 } if (response.status === 403 && this._authProvider) { const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); -@@ -358,7 +399,7 @@ export class StreamableHTTPClientTransport { +@@ -358,7 +401,7 @@ export class StreamableHTTPClientTransport { if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); } @@ -408,7 +520,7 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88 } throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); diff --git a/dist/esm/shared/protocol.js b/dist/esm/shared/protocol.js -index bfa2b7120a0f50c569364ea5264e6f811076f44f..abd8dfd707c155f71dae7aeeeeaf7547368ac749 100644 +index bfa2b7120a0f50c569364ea5264e6f811076f44f..dec477d16a0fd796854542c1144279a6e86567f2 100644 --- a/dist/esm/shared/protocol.js +++ b/dist/esm/shared/protocol.js @@ -740,7 +740,12 @@ export class Protocol { From cb85fbc11e5956c9ad02305140e54d23aed5fe70 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 24 Jun 2026 17:06:53 -0500 Subject: [PATCH 2/3] test(mcp): isolate OAuth transport integration --- packages/opencode/test/mcp/oauth-header-isolation.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/mcp/oauth-header-isolation.test.ts b/packages/opencode/test/mcp/oauth-header-isolation.test.ts index 5516241b5b28..3d1a4f11a965 100644 --- a/packages/opencode/test/mcp/oauth-header-isolation.test.ts +++ b/packages/opencode/test/mcp/oauth-header-isolation.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test" import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" -import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js" +// @ts-expect-error Bun's query cache key avoids process-global module mocks from other test files. +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js?oauth-header-isolation" const canary = "resource-secret" const staticAuthorization = "Bearer static-resource-token" @@ -57,7 +57,7 @@ describe("MCP OAuth header isolation", () => { }) await transport.start() - await expect(transport.send(request())).rejects.toBeInstanceOf(UnauthorizedError) + await expect(transport.send(request())).rejects.toThrow("Unauthorized") await transport.finishAuth("authorization-code") await transport.close() From 593fcbdc2e27ba8a3b2f80f37216cccc0e6a4925 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 24 Jun 2026 18:16:47 -0500 Subject: [PATCH 3/3] fix(mcp): scope OAuth resource headers --- .../test/mcp/oauth-header-isolation.test.ts | 63 ++++ .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 290 ++++++++++++++++-- 2 files changed, 331 insertions(+), 22 deletions(-) diff --git a/packages/opencode/test/mcp/oauth-header-isolation.test.ts b/packages/opencode/test/mcp/oauth-header-isolation.test.ts index 3d1a4f11a965..1117b199bf8b 100644 --- a/packages/opencode/test/mcp/oauth-header-isolation.test.ts +++ b/packages/opencode/test/mcp/oauth-header-isolation.test.ts @@ -13,6 +13,69 @@ const staticAuthorization = "Bearer static-resource-token" const basicAuthorization = `Basic ${btoa("client:secret")}` describe("MCP OAuth header isolation", () => { + test("keeps resource headers on same-origin protected resource metadata", async () => { + using authorizationServer = serve(() => + Response.json({ + issuer: authorizationServer.origin, + authorization_endpoint: `${authorizationServer.origin}/authorize`, + token_endpoint: `${authorizationServer.origin}/token`, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + ) + using resource = serve((request) => { + if (new URL(request.url).pathname === "/prm") { + return Response.json({ + resource: `${resource.origin}/mcp`, + authorization_servers: [authorizationServer.origin], + }) + } + return new Response(null, { + status: 401, + headers: { "WWW-Authenticate": `Bearer resource_metadata="${resource.origin}/prm"` }, + }) + }) + const transport = new StreamableHTTPClientTransport(new URL(`${resource.origin}/mcp`), { + authProvider: createProvider({ + clientInformation: { client_id: "client", token_endpoint_auth_method: "none" }, + }), + requestInit: { headers: { "X-Resource-Canary": canary, Authorization: staticAuthorization } }, + }) + + await transport.start() + await expect(transport.send(request())).rejects.toThrow("Unauthorized") + await transport.close() + + expect(resource.requests.map((request) => new URL(request.url).pathname)).toEqual(["/mcp", "/prm"]) + expectResourceHeaders(resource.requests) + expectNoResourceHeaders(authorizationServer.requests) + }) + + test("does not follow protected resource metadata redirects with resource headers", async () => { + using target = serve(() => Response.json({})) + using resource = serve((request) => { + if (new URL(request.url).pathname === "/prm") { + return Response.redirect(`${target.origin}/prm`) + } + return new Response(null, { + status: 401, + headers: { "WWW-Authenticate": `Bearer resource_metadata="${resource.origin}/prm"` }, + }) + }) + const transport = new StreamableHTTPClientTransport(new URL(`${resource.origin}/mcp`), { + authProvider: createProvider({ + clientInformation: { client_id: "client", token_endpoint_auth_method: "none" }, + }), + requestInit: { headers: { "X-Resource-Canary": canary } }, + }) + + await transport.start() + await expect(transport.send(request())).rejects.toThrow("Unauthorized") + await transport.close() + + expect(target.requests).toHaveLength(0) + }) + test("keeps resource headers out of discovery, registration, and token exchange", async () => { using token = serve(() => Response.json({ access_token: "access", token_type: "Bearer", refresh_token: "refresh" })) using registration = serve(() => diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index 241b5f99b704..9fd22af8e6a4 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -1,3 +1,57 @@ +diff --git a/dist/cjs/client/auth.js b/dist/cjs/client/auth.js +index c2e4fa91d26f5336889f6afa416147db75fc4872..6fe80dd336252838adf47b0a8e022e55d11e3657 100644 +--- a/dist/cjs/client/auth.js ++++ b/dist/cjs/client/auth.js +@@ -186,7 +186,7 @@ async function auth(provider, options) { + throw error; + } + } +-async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) { ++async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn, resourceMetadataFetchFn }) { + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); + let resourceMetadata; +@@ -198,6 +198,9 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res + if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { + effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); + } ++ const protectedResourceFetchFn = !effectiveResourceMetadataUrl || effectiveResourceMetadataUrl.origin === new URL(serverUrl).origin ++ ? (resourceMetadataFetchFn ?? fetchFn) ++ : fetchFn; + if (cachedState?.authorizationServerUrl) { + // Restore discovery state from cache + authorizationServerUrl = cachedState.authorizationServerUrl; +@@ -207,7 +210,7 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res + // If resource metadata wasn't cached, try to fetch it for selectResourceURL + if (!resourceMetadata) { + try { +- resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl }, fetchFn); ++ resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl }, protectedResourceFetchFn); + } + catch { + // RFC 9728 not available — selectResourceURL will handle undefined +@@ -225,7 +228,11 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res + } + else { + // Full discovery via RFC 9728 +- const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); ++ const serverInfo = await discoverOAuthServerInfo(serverUrl, { ++ resourceMetadataUrl: effectiveResourceMetadataUrl, ++ fetchFn, ++ resourceMetadataFetchFn: protectedResourceFetchFn ++ }); + authorizationServerUrl = serverInfo.authorizationServerUrl; + metadata = serverInfo.authorizationServerMetadata; + resourceMetadata = serverInfo.resourceMetadata; +@@ -688,7 +695,7 @@ async function discoverOAuthServerInfo(serverUrl, opts) { + let resourceMetadata; + let authorizationServerUrl; + try { +- resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: opts?.resourceMetadataUrl }, opts?.fetchFn); ++ resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: opts?.resourceMetadataUrl }, opts?.resourceMetadataFetchFn ?? opts?.fetchFn); + if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { + authorizationServerUrl = resourceMetadata.authorization_servers[0]; + } diff --git a/dist/cjs/client/index.d.ts b/dist/cjs/client/index.d.ts index 6f567a193626587a2730b5a49293ca5dfd4181ea..5b7c841c000508e389ce617f559f7c2a5126ca9f 100644 --- a/dist/cjs/client/index.d.ts @@ -100,20 +154,31 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..88e58b90b673cb0f9c60920edec7d2eb * After initialization has completed, this will be populated with the server's reported capabilities. */ diff --git a/dist/cjs/client/sse.js b/dist/cjs/client/sse.js -index 8ca8dad652e76c299927f836cf622c0be877e1d4..cfbb08438a4e219fcd98d8d4ba8949079f3fcbe5 100644 +index 8ca8dad652e76c299927f836cf622c0be877e1d4..14cc1b969ac51ecb6f894608f079f4f04d8acd62 100644 --- a/dist/cjs/client/sse.js +++ b/dist/cjs/client/sse.js -@@ -27,7 +27,8 @@ class SSEClientTransport { +@@ -27,7 +27,9 @@ class SSEClientTransport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; - this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, opts?.requestInit); ++ this._resourceFetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, { ...opts?.requestInit, redirect: 'error' }); + const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; + this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, oauthRequestInit); } async _authThenStart() { if (!this._authProvider) { -@@ -63,10 +64,11 @@ class SSEClientTransport { +@@ -39,7 +41,8 @@ class SSEClientTransport { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetchWithInit ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); + } + catch (error) { +@@ -63,10 +66,11 @@ class SSEClientTransport { headers['mcp-protocol-version'] = this._protocolVersion; } const extraHeaders = (0, transport_js_1.normalizeHeaders)(this._requestInit?.headers); @@ -129,21 +194,52 @@ index 8ca8dad652e76c299927f836cf622c0be877e1d4..cfbb08438a4e219fcd98d8d4ba894907 } _startOrAuth() { const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch); +@@ -149,7 +153,8 @@ class SSEClientTransport { + authorizationCode, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetchWithInit ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); + if (result !== 'AUTHORIZED') { + throw new auth_js_1.UnauthorizedError('Failed to authorize'); +@@ -185,7 +190,8 @@ class SSEClientTransport { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetchWithInit ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); + if (result !== 'AUTHORIZED') { + throw new auth_js_1.UnauthorizedError(); diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.js -index a29a7d3a0f14d9cd800ef5b296485237350c666f..f253f88023c10d44e6cdb74bcf2ffa722619fd0d 100644 +index a29a7d3a0f14d9cd800ef5b296485237350c666f..54ac759eb19df7c6f16fc031a689270acb5c3922 100644 --- a/dist/cjs/client/streamableHttp.js +++ b/dist/cjs/client/streamableHttp.js -@@ -33,7 +33,8 @@ class StreamableHTTPClientTransport { +@@ -33,7 +33,9 @@ class StreamableHTTPClientTransport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; - this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, opts?.requestInit); ++ this._resourceFetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, { ...opts?.requestInit, redirect: 'error' }); + const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; + this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, oauthRequestInit); this._sessionId = opts?.sessionId; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; } -@@ -74,10 +75,11 @@ class StreamableHTTPClientTransport { +@@ -47,7 +49,8 @@ class StreamableHTTPClientTransport { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetchWithInit ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); + } + catch (error) { +@@ -74,10 +77,11 @@ class StreamableHTTPClientTransport { headers['mcp-protocol-version'] = this._protocolVersion; } const extraHeaders = (0, transport_js_1.normalizeHeaders)(this._requestInit?.headers); @@ -159,7 +255,17 @@ index a29a7d3a0f14d9cd800ef5b296485237350c666f..f253f88023c10d44e6cdb74bcf2ffa72 } async _startOrAuthSse(options) { const { resumptionToken } = options; -@@ -290,7 +292,38 @@ class StreamableHTTPClientTransport { +@@ -275,7 +279,8 @@ class StreamableHTTPClientTransport { + authorizationCode, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetchWithInit ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); + if (result !== 'AUTHORIZED') { + throw new auth_js_1.UnauthorizedError('Failed to authorize'); +@@ -290,7 +295,38 @@ class StreamableHTTPClientTransport { this.onclose?.(); } async send(message, options) { @@ -198,7 +304,7 @@ index a29a7d3a0f14d9cd800ef5b296485237350c666f..f253f88023c10d44e6cdb74bcf2ffa72 const { resumptionToken, onresumptiontoken } = options || {}; if (resumptionToken) { // If we have at last event ID, we need to reconnect the SSE stream -@@ -298,6 +331,7 @@ class StreamableHTTPClientTransport { +@@ -298,6 +334,7 @@ class StreamableHTTPClientTransport { return; } const headers = await this._commonHeaders(); @@ -206,7 +312,7 @@ index a29a7d3a0f14d9cd800ef5b296485237350c666f..f253f88023c10d44e6cdb74bcf2ffa72 headers.set('content-type', 'application/json'); headers.set('accept', 'application/json, text/event-stream'); const init = { -@@ -310,11 +344,20 @@ class StreamableHTTPClientTransport { +@@ -310,11 +347,20 @@ class StreamableHTTPClientTransport { const response = await (this._fetch ?? fetch)(this._url, init); // Handle session ID received during initialization const sessionId = response.headers.get('mcp-session-id'); @@ -228,7 +334,17 @@ index a29a7d3a0f14d9cd800ef5b296485237350c666f..f253f88023c10d44e6cdb74bcf2ffa72 if (response.status === 401 && this._authProvider) { // Prevent infinite recursion when server returns 401 after successful auth if (this._hasCompletedAuthFlow) { -@@ -335,7 +378,7 @@ class StreamableHTTPClientTransport { +@@ -327,7 +373,8 @@ class StreamableHTTPClientTransport { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetchWithInit ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); + if (result !== 'AUTHORIZED') { + throw new auth_js_1.UnauthorizedError(); +@@ -335,7 +382,7 @@ class StreamableHTTPClientTransport { // Mark that we completed auth flow this._hasCompletedAuthFlow = true; // Purposely _not_ awaited, so we don't call onerror twice @@ -237,7 +353,14 @@ index a29a7d3a0f14d9cd800ef5b296485237350c666f..f253f88023c10d44e6cdb74bcf2ffa72 } if (response.status === 403 && this._authProvider) { const { resourceMetadataUrl, scope, error } = (0, auth_js_1.extractWWWAuthenticateParams)(response); -@@ -362,7 +405,7 @@ class StreamableHTTPClientTransport { +@@ -357,12 +404,13 @@ class StreamableHTTPClientTransport { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetch ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); if (result !== 'AUTHORIZED') { throw new auth_js_1.UnauthorizedError(); } @@ -264,6 +387,60 @@ index 3617e787f0ba70447c99501aee7aa67584d89758..4ee4d158391558fdc1f977f5134b7cac this._cleanupTimeout(messageId); reject(error); }); +diff --git a/dist/esm/client/auth.js b/dist/esm/client/auth.js +index e183040fc2bba22ca1ccc784984f3310854403b7..6b88910ca88f9714abe7cad26fbf3a31c4fb376c 100644 +--- a/dist/esm/client/auth.js ++++ b/dist/esm/client/auth.js +@@ -161,7 +161,7 @@ export async function auth(provider, options) { + throw error; + } + } +-async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) { ++async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn, resourceMetadataFetchFn }) { + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); + let resourceMetadata; +@@ -173,6 +173,9 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res + if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { + effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); + } ++ const protectedResourceFetchFn = !effectiveResourceMetadataUrl || effectiveResourceMetadataUrl.origin === new URL(serverUrl).origin ++ ? (resourceMetadataFetchFn ?? fetchFn) ++ : fetchFn; + if (cachedState?.authorizationServerUrl) { + // Restore discovery state from cache + authorizationServerUrl = cachedState.authorizationServerUrl; +@@ -182,7 +185,7 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res + // If resource metadata wasn't cached, try to fetch it for selectResourceURL + if (!resourceMetadata) { + try { +- resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl }, fetchFn); ++ resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl }, protectedResourceFetchFn); + } + catch { + // RFC 9728 not available — selectResourceURL will handle undefined +@@ -200,7 +203,11 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res + } + else { + // Full discovery via RFC 9728 +- const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); ++ const serverInfo = await discoverOAuthServerInfo(serverUrl, { ++ resourceMetadataUrl: effectiveResourceMetadataUrl, ++ fetchFn, ++ resourceMetadataFetchFn: protectedResourceFetchFn ++ }); + authorizationServerUrl = serverInfo.authorizationServerUrl; + metadata = serverInfo.authorizationServerMetadata; + resourceMetadata = serverInfo.resourceMetadata; +@@ -663,7 +670,7 @@ export async function discoverOAuthServerInfo(serverUrl, opts) { + let resourceMetadata; + let authorizationServerUrl; + try { +- resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: opts?.resourceMetadataUrl }, opts?.fetchFn); ++ resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: opts?.resourceMetadataUrl }, opts?.resourceMetadataFetchFn ?? opts?.fetchFn); + if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { + authorizationServerUrl = resourceMetadata.authorization_servers[0]; + } diff --git a/dist/esm/client/index.d.ts b/dist/esm/client/index.d.ts index 6f567a193626587a2730b5a49293ca5dfd4181ea..5b7c841c000508e389ce617f559f7c2a5126ca9f 100644 --- a/dist/esm/client/index.d.ts @@ -366,20 +543,31 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..339153cb7e9299b7a9bdec0e56e41ced * After initialization has completed, this will be populated with the server's reported capabilities. */ diff --git a/dist/esm/client/sse.js b/dist/esm/client/sse.js -index 58c474156ba4b33090ac092be6f3284e695a7ffd..ca088b48eeb04a9b65863d9a6dcd3bd11c4a8f71 100644 +index 58c474156ba4b33090ac092be6f3284e695a7ffd..b813e467b497697208167ad9bfccdecd1200ffac 100644 --- a/dist/esm/client/sse.js +++ b/dist/esm/client/sse.js -@@ -23,7 +23,8 @@ export class SSEClientTransport { +@@ -23,7 +23,9 @@ export class SSEClientTransport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; - this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); ++ this._resourceFetchWithInit = createFetchWithInit(opts?.fetch, { ...opts?.requestInit, redirect: 'error' }); + const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; + this._fetchWithInit = createFetchWithInit(opts?.fetch, oauthRequestInit); } async _authThenStart() { if (!this._authProvider) { -@@ -59,10 +60,11 @@ export class SSEClientTransport { +@@ -35,7 +37,8 @@ export class SSEClientTransport { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetchWithInit ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); + } + catch (error) { +@@ -59,10 +62,11 @@ export class SSEClientTransport { headers['mcp-protocol-version'] = this._protocolVersion; } const extraHeaders = normalizeHeaders(this._requestInit?.headers); @@ -395,8 +583,28 @@ index 58c474156ba4b33090ac092be6f3284e695a7ffd..ca088b48eeb04a9b65863d9a6dcd3bd1 } _startOrAuth() { const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch); +@@ -145,7 +149,8 @@ export class SSEClientTransport { + authorizationCode, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetchWithInit ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError('Failed to authorize'); +@@ -181,7 +186,8 @@ export class SSEClientTransport { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetchWithInit ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); diff --git a/dist/esm/client/streamableHttp.js b/dist/esm/client/streamableHttp.js -index 624172aa24ae255a67c083f9c19053343e4a0581..d833dce32e6dc90a5b72dee03e70acd0676034da 100644 +index 624172aa24ae255a67c083f9c19053343e4a0581..88f7f5c3528d0ce6115efbebfdda98eba7077b8b 100644 --- a/dist/esm/client/streamableHttp.js +++ b/dist/esm/client/streamableHttp.js @@ -1,5 +1,5 @@ @@ -406,17 +614,28 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..d833dce32e6dc90a5b72dee03e70acd0 import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; // Default reconnection options for StreamableHTTP connections -@@ -29,7 +29,8 @@ export class StreamableHTTPClientTransport { +@@ -29,7 +29,9 @@ export class StreamableHTTPClientTransport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; - this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); ++ this._resourceFetchWithInit = createFetchWithInit(opts?.fetch, { ...opts?.requestInit, redirect: 'error' }); + const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; + this._fetchWithInit = createFetchWithInit(opts?.fetch, oauthRequestInit); this._sessionId = opts?.sessionId; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; } -@@ -70,10 +71,11 @@ export class StreamableHTTPClientTransport { +@@ -43,7 +45,8 @@ export class StreamableHTTPClientTransport { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetchWithInit ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); + } + catch (error) { +@@ -70,10 +73,11 @@ export class StreamableHTTPClientTransport { headers['mcp-protocol-version'] = this._protocolVersion; } const extraHeaders = normalizeHeaders(this._requestInit?.headers); @@ -432,7 +651,17 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..d833dce32e6dc90a5b72dee03e70acd0 } async _startOrAuthSse(options) { const { resumptionToken } = options; -@@ -286,7 +288,38 @@ export class StreamableHTTPClientTransport { +@@ -271,7 +275,8 @@ export class StreamableHTTPClientTransport { + authorizationCode, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetchWithInit ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError('Failed to authorize'); +@@ -286,7 +291,38 @@ export class StreamableHTTPClientTransport { this.onclose?.(); } async send(message, options) { @@ -471,7 +700,7 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..d833dce32e6dc90a5b72dee03e70acd0 const { resumptionToken, onresumptiontoken } = options || {}; if (resumptionToken) { // If we have at last event ID, we need to reconnect the SSE stream -@@ -294,6 +327,7 @@ export class StreamableHTTPClientTransport { +@@ -294,6 +330,7 @@ export class StreamableHTTPClientTransport { return; } const headers = await this._commonHeaders(); @@ -479,7 +708,7 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..d833dce32e6dc90a5b72dee03e70acd0 headers.set('content-type', 'application/json'); headers.set('accept', 'application/json, text/event-stream'); const init = { -@@ -306,11 +340,20 @@ export class StreamableHTTPClientTransport { +@@ -306,11 +343,20 @@ export class StreamableHTTPClientTransport { const response = await (this._fetch ?? fetch)(this._url, init); // Handle session ID received during initialization const sessionId = response.headers.get('mcp-session-id'); @@ -501,7 +730,17 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..d833dce32e6dc90a5b72dee03e70acd0 if (response.status === 401 && this._authProvider) { // Prevent infinite recursion when server returns 401 after successful auth if (this._hasCompletedAuthFlow) { -@@ -331,7 +374,7 @@ export class StreamableHTTPClientTransport { +@@ -323,7 +369,8 @@ export class StreamableHTTPClientTransport { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetchWithInit ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); +@@ -331,7 +378,7 @@ export class StreamableHTTPClientTransport { // Mark that we completed auth flow this._hasCompletedAuthFlow = true; // Purposely _not_ awaited, so we don't call onerror twice @@ -510,7 +749,14 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..d833dce32e6dc90a5b72dee03e70acd0 } if (response.status === 403 && this._authProvider) { const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); -@@ -358,7 +401,7 @@ export class StreamableHTTPClientTransport { +@@ -353,12 +400,13 @@ export class StreamableHTTPClientTransport { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, +- fetchFn: this._fetch ++ fetchFn: this._fetchWithInit, ++ resourceMetadataFetchFn: this._resourceFetchWithInit + }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); }