Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/opencode/src/cli/cmd/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {
Expand Down
135 changes: 135 additions & 0 deletions packages/opencode/test/mcp/headers-start-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> } | 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<void> }) {
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()
},
})
})
Loading