Skip to content

Commit d2f2d42

Browse files
committed
fix(opencode): forward configured headers in startAuth and mcp debug OAuth transports
1 parent 434d82b commit d2f2d42

3 files changed

Lines changed: 141 additions & 1 deletion

File tree

packages/opencode/src/cli/cmd/mcp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,7 @@ export const McpDebugCommand = cmd({
655655
headers: {
656656
"Content-Type": "application/json",
657657
Accept: "application/json, text/event-stream",
658+
...serverConfig.headers,
658659
},
659660
body: JSON.stringify({
660661
jsonrpc: "2.0",
@@ -699,6 +700,7 @@ export const McpDebugCommand = cmd({
699700
// Try creating transport with auth provider to trigger discovery
700701
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
701702
authProvider,
703+
requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
702704
})
703705

704706
try {

packages/opencode/src/mcp/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -739,7 +739,10 @@ export namespace MCP {
739739
},
740740
)
741741

742-
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
742+
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), {
743+
authProvider,
744+
requestInit: mcpConfig.headers ? { headers: mcpConfig.headers } : undefined,
745+
})
743746

744747
return yield* Effect.tryPromise({
745748
try: () => {
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { test, expect, mock, beforeEach } from "bun:test"
2+
3+
// Mock UnauthorizedError so instanceof checks in startAuth work
4+
class MockUnauthorizedError extends Error {
5+
constructor() {
6+
super("Unauthorized")
7+
this.name = "UnauthorizedError"
8+
}
9+
}
10+
11+
// Track constructor arguments for all StreamableHTTPClientTransport instances
12+
const transportCalls: Array<{
13+
url: string
14+
options: { authProvider?: unknown; requestInit?: RequestInit }
15+
}> = []
16+
17+
// The mock transport simulates a 401 that captures the auth URL via
18+
// authProvider.redirectToAuthorization(), then throws UnauthorizedError —
19+
// exactly what the real SDK transport does on a 401 response.
20+
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
21+
StreamableHTTPClientTransport: class MockStreamableHTTP {
22+
authProvider: { redirectToAuthorization?: (url: URL) => Promise<void> } | undefined
23+
constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
24+
this.authProvider = options?.authProvider as typeof this.authProvider
25+
transportCalls.push({ url: url.toString(), options: options ?? {} })
26+
}
27+
async start() {
28+
if (this.authProvider?.redirectToAuthorization) {
29+
await this.authProvider.redirectToAuthorization(
30+
new URL("https://auth.example.com/authorize?client_id=test"),
31+
)
32+
}
33+
throw new MockUnauthorizedError()
34+
}
35+
async finishAuth(_code: string) {}
36+
},
37+
}))
38+
39+
mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
40+
SSEClientTransport: class MockSSE {
41+
constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
42+
transportCalls.push({ url: url.toString(), options: options ?? {} })
43+
}
44+
async start() {
45+
throw new Error("Mock SSE transport cannot connect")
46+
}
47+
},
48+
}))
49+
50+
mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
51+
Client: class MockClient {
52+
async connect(transport: { start: () => Promise<void> }) {
53+
await transport.start()
54+
}
55+
},
56+
}))
57+
58+
mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
59+
UnauthorizedError: MockUnauthorizedError,
60+
}))
61+
62+
beforeEach(() => {
63+
transportCalls.length = 0
64+
})
65+
66+
// Import modules after mocking
67+
const { MCP } = await import("../../src/mcp/index")
68+
const { Instance } = await import("../../src/project/instance")
69+
const { tmpdir } = await import("../fixture/fixture")
70+
71+
test("startAuth passes headers to StreamableHTTPClientTransport", async () => {
72+
await using tmp = await tmpdir({
73+
init: async (dir) => {
74+
await Bun.write(
75+
`${dir}/opencode.json`,
76+
JSON.stringify({
77+
mcp: {
78+
"test-server": {
79+
type: "remote",
80+
url: "https://example.com/mcp",
81+
headers: { "X-Custom-Header": "realm-value" },
82+
oauth: {},
83+
},
84+
},
85+
}),
86+
)
87+
},
88+
})
89+
90+
await Instance.provide({
91+
directory: tmp.path,
92+
fn: async () => {
93+
// startAuth throws (no real callback server) but we only care that the
94+
// transport was constructed with the correct requestInit before that.
95+
await MCP.startAuth("test-server").catch(() => {})
96+
97+
const startAuthCall = transportCalls.find((c) => c.url === "https://example.com/mcp")
98+
expect(startAuthCall).toBeDefined()
99+
expect(startAuthCall!.options.requestInit).toBeDefined()
100+
expect(startAuthCall!.options.requestInit?.headers).toEqual({
101+
"X-Custom-Header": "realm-value",
102+
})
103+
},
104+
})
105+
})
106+
107+
test("startAuth does not set requestInit when no headers are configured", async () => {
108+
await using tmp = await tmpdir({
109+
init: async (dir) => {
110+
await Bun.write(
111+
`${dir}/opencode.json`,
112+
JSON.stringify({
113+
mcp: {
114+
"test-server-no-headers": {
115+
type: "remote",
116+
url: "https://example.com/mcp",
117+
oauth: {},
118+
},
119+
},
120+
}),
121+
)
122+
},
123+
})
124+
125+
await Instance.provide({
126+
directory: tmp.path,
127+
fn: async () => {
128+
await MCP.startAuth("test-server-no-headers").catch(() => {})
129+
130+
const startAuthCall = transportCalls.find((c) => c.url === "https://example.com/mcp")
131+
expect(startAuthCall).toBeDefined()
132+
expect(startAuthCall!.options.requestInit).toBeUndefined()
133+
},
134+
})
135+
})

0 commit comments

Comments
 (0)