diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c45b9e55d0f8..a659cf7ebc0a 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -655,6 +655,7 @@ export const McpDebugCommand = cmd({ headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", + ...serverConfig.headers, }, body: JSON.stringify({ jsonrpc: "2.0", @@ -699,6 +700,7 @@ export const McpDebugCommand = cmd({ // Try creating transport with auth provider to trigger discovery const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { authProvider, + requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined, }) try { diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 47c39aad5646..f7310340b503 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -739,7 +739,10 @@ export namespace MCP { }, ) - const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider }) + const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { + authProvider, + requestInit: mcpConfig.headers ? { headers: mcpConfig.headers } : undefined, + }) return yield* Effect.tryPromise({ try: () => { diff --git a/packages/opencode/test/mcp/headers-start-auth.test.ts b/packages/opencode/test/mcp/headers-start-auth.test.ts new file mode 100644 index 000000000000..35b577d15f76 --- /dev/null +++ b/packages/opencode/test/mcp/headers-start-auth.test.ts @@ -0,0 +1,135 @@ +import { test, expect, mock, beforeEach } from "bun:test" + +// Mock UnauthorizedError so instanceof checks in startAuth work +class MockUnauthorizedError extends Error { + constructor() { + super("Unauthorized") + this.name = "UnauthorizedError" + } +} + +// Track constructor arguments for all StreamableHTTPClientTransport instances +const transportCalls: Array<{ + url: string + options: { authProvider?: unknown; requestInit?: RequestInit } +}> = [] + +// The mock transport simulates a 401 that captures the auth URL via +// authProvider.redirectToAuthorization(), then throws UnauthorizedError — +// exactly what the real SDK transport does on a 401 response. +mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ + StreamableHTTPClientTransport: class MockStreamableHTTP { + authProvider: { redirectToAuthorization?: (url: URL) => Promise } | undefined + constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) { + this.authProvider = options?.authProvider as typeof this.authProvider + transportCalls.push({ url: url.toString(), options: options ?? {} }) + } + async start() { + if (this.authProvider?.redirectToAuthorization) { + await this.authProvider.redirectToAuthorization( + new URL("https://auth.example.com/authorize?client_id=test"), + ) + } + throw new MockUnauthorizedError() + } + async finishAuth(_code: string) {} + }, +})) + +mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ + SSEClientTransport: class MockSSE { + constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) { + transportCalls.push({ url: url.toString(), options: options ?? {} }) + } + async start() { + throw new Error("Mock SSE transport cannot connect") + } + }, +})) + +mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ + Client: class MockClient { + async connect(transport: { start: () => Promise }) { + await transport.start() + } + }, +})) + +mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ + UnauthorizedError: MockUnauthorizedError, +})) + +beforeEach(() => { + transportCalls.length = 0 +}) + +// Import modules after mocking +const { MCP } = await import("../../src/mcp/index") +const { Instance } = await import("../../src/project/instance") +const { tmpdir } = await import("../fixture/fixture") + +test("startAuth passes headers to StreamableHTTPClientTransport", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + `${dir}/opencode.json`, + JSON.stringify({ + mcp: { + "test-server": { + type: "remote", + url: "https://example.com/mcp", + headers: { "X-Custom-Header": "realm-value" }, + oauth: {}, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // startAuth throws (no real callback server) but we only care that the + // transport was constructed with the correct requestInit before that. + await MCP.startAuth("test-server").catch(() => {}) + + const startAuthCall = transportCalls.find((c) => c.url === "https://example.com/mcp") + expect(startAuthCall).toBeDefined() + expect(startAuthCall!.options.requestInit).toBeDefined() + expect(startAuthCall!.options.requestInit?.headers).toEqual({ + "X-Custom-Header": "realm-value", + }) + }, + }) +}) + +test("startAuth does not set requestInit when no headers are configured", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + `${dir}/opencode.json`, + JSON.stringify({ + mcp: { + "test-server-no-headers": { + type: "remote", + url: "https://example.com/mcp", + oauth: {}, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await MCP.startAuth("test-server-no-headers").catch(() => {}) + + const startAuthCall = transportCalls.find((c) => c.url === "https://example.com/mcp") + expect(startAuthCall).toBeDefined() + expect(startAuthCall!.options.requestInit).toBeUndefined() + }, + }) +})