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..1117b199bf8b --- /dev/null +++ b/packages/opencode/test/mcp/oauth-header-isolation.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "bun:test" +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.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" +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(() => + 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.toThrow("Unauthorized") + 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..9fd22af8e6a4 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -1,5 +1,59 @@ +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 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 +153,119 @@ 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..14cc1b969ac51ecb6f894608f079f4f04d8acd62 100644 +--- a/dist/cjs/client/sse.js ++++ b/dist/cjs/client/sse.js +@@ -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) { +@@ -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); +- 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); +@@ -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..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644 +index a29a7d3a0f14d9cd800ef5b296485237350c666f..54ac759eb19df7c6f16fc031a689270acb5c3922 100644 --- a/dist/cjs/client/streamableHttp.js +++ b/dist/cjs/client/streamableHttp.js -@@ -290,7 +290,38 @@ 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; + } +@@ -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); +- 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; +@@ -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) { @@ -155,7 +304,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 +334,7 @@ class StreamableHTTPClientTransport { return; } const headers = await this._commonHeaders(); @@ -163,7 +312,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 +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'); @@ -185,7 +334,17 @@ 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 { +@@ -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 @@ -194,7 +353,14 @@ 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 { +@@ -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(); } @@ -204,7 +370,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 +387,75 @@ index 3617e787f0ba70447c99501aee7aa67584d89758..4a96d6a0328fa348b96f3869ab7e0bb7 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 ++++ 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 +542,69 @@ 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..b813e467b497697208167ad9bfccdecd1200ffac 100644 +--- a/dist/esm/client/sse.js ++++ b/dist/esm/client/sse.js +@@ -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) { +@@ -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); +- 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); +@@ -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..ac75b14545fda44aff7ff4d97cc5da884fcc627a 100644 +index 624172aa24ae255a67c083f9c19053343e4a0581..88f7f5c3528d0ce6115efbebfdda98eba7077b8b 100644 --- a/dist/esm/client/streamableHttp.js +++ b/dist/esm/client/streamableHttp.js @@ -1,5 +1,5 @@ @@ -320,7 +614,54 @@ 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,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; + } +@@ -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); +- 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; +@@ -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) { @@ -359,7 +700,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 +330,7 @@ export class StreamableHTTPClientTransport { return; } const headers = await this._commonHeaders(); @@ -367,7 +708,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 +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'); @@ -389,7 +730,17 @@ 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 { +@@ -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 @@ -398,7 +749,14 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88 } if (response.status === 403 && this._authProvider) { const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); -@@ -358,7 +399,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(); } @@ -408,7 +766,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 {