Skip to content
Merged
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
37 changes: 37 additions & 0 deletions packages/opencode/src/mcp/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Context, Effect, Layer } from "effect"
import open from "open"

export interface Interface {
readonly open: (url: string) => Effect.Effect<void, Error>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/McpBrowser") {}

const layer = Layer.succeed(
Service,
Service.of({
open: Effect.fn("McpBrowser.open")(function* (url: string) {
const subprocess = yield* Effect.tryPromise({
try: () => open(url),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
yield* Effect.callback<void, Error>((resume) => {
const timer = setTimeout(() => resume(Effect.void), 500)
subprocess.on("error", (error) => {
clearTimeout(timer)
resume(Effect.fail(error))
})
subprocess.on("exit", (code) => {
if (code === null || code === 0) return
clearTimeout(timer)
resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`)))
})
})
}),
}),
)

export const node = LayerNode.make({ service: Service, layer, deps: [] })

export * as McpBrowser from "./browser"
22 changes: 4 additions & 18 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth"
import { EventV2Bridge } from "@/event-v2-bridge"
import { TuiEvent } from "@/server/tui-event"
import open from "open"
import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { McpCatalog } from "./catalog"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { McpBrowser } from "./browser"

const DEFAULT_TIMEOUT = 30_000
const CLIENT_OPTIONS = {
Expand Down Expand Up @@ -207,6 +207,7 @@ const layer = Layer.effect(
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const auth = yield* McpAuth.Service
const events = yield* EventV2Bridge.Service
const browser = yield* McpBrowser.Service

type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport

Expand Down Expand Up @@ -897,22 +898,7 @@ const layer = Layer.effect(
const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
onAuthorization?.(result.authorizationUrl)

yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe(
Effect.flatMap((subprocess) =>
Effect.callback<void, Error>((resume) => {
const timer = setTimeout(() => resume(Effect.void), 500)
subprocess.on("error", (err) => {
clearTimeout(timer)
resume(Effect.fail(err))
})
subprocess.on("exit", (code) => {
if (code !== null && code !== 0) {
clearTimeout(timer)
resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`)))
}
})
}),
),
yield* browser.open(result.authorizationUrl).pipe(
Effect.catch(() => {
return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
}),
Expand Down Expand Up @@ -1012,7 +998,7 @@ export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node],
deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node, McpBrowser.node],
})

export * as MCP from "."
26 changes: 26 additions & 0 deletions packages/opencode/test/fixture/mcp-lifecycle-stdio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"

if (process.argv.includes("--hang")) {
const pidFile = process.env.MCP_LIFECYCLE_PID_FILE
if (!pidFile) throw new Error("MCP_LIFECYCLE_PID_FILE is required")
await Bun.write(pidFile, String(process.pid))
await new Promise(() => {})
}

const server = new Server({ name: "mcp-lifecycle-stdio", version: "1.0.0" }, { capabilities: { tools: {} } })

server.setRequestHandler(ListToolsRequestSchema, () =>
Promise.resolve({
tools: [
{
name: "current_directory",
description: process.cwd(),
inputSchema: { type: "object", properties: {} },
},
],
}),
)

await server.connect(new StdioServerTransport())
163 changes: 69 additions & 94 deletions packages/opencode/test/mcp/headers.test.ts
Original file line number Diff line number Diff line change
@@ -1,126 +1,101 @@
import { describe, expect, mock, beforeEach } from "bun:test"
import { describe, expect } from "bun:test"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { MCP } from "../../src/mcp/index"

// Track what options were passed to each transport constructor
const transportCalls: Array<{
type: "streamable" | "sse"
url: string
options: { authProvider?: unknown; requestInit?: RequestInit }
}> = []

// Mock the transport constructors to capture their arguments
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
StreamableHTTPClientTransport: class MockStreamableHTTP {
constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
transportCalls.push({
type: "streamable",
url: url.toString(),
options: options ?? {},
})
}
async start() {
throw new Error("Mock transport cannot connect")
}
},
}))
const it = testEffect(LayerNode.compile(MCP.node))

void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: class MockSSE {
constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
transportCalls.push({
type: "sse",
url: url.toString(),
options: options ?? {},
})
const serve = Effect.acquireRelease(
Effect.promise(async () => {
const requests: Headers[] = []
const protocol = new Server({ name: "headers", version: "1.0.0" }, { capabilities: { tools: {} } })
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
})
await protocol.connect(transport)
const http = Bun.serve({
port: 0,
fetch(request) {
requests.push(new Headers(request.headers))
return transport.handleRequest(request)
},
})
return {
requests,
url: http.url.toString(),
close: async () => {
await http.stop(true)
await protocol.close()
},
}
async start() {
throw new Error("Mock transport cannot connect")
}
},
}))

beforeEach(() => {
transportCalls.length = 0
})

// Import MCP after mocking
const { MCP } = await import("../../src/mcp/index")
const it = testEffect(LayerNode.compile(MCP.node))
}),
(server) => Effect.promise(server.close),
)

describe("mcp.headers", () => {
it.instance("headers are passed to transports when oauth is enabled (default)", () =>
Effect.gen(function* () {
const server = yield* serve
const mcp = yield* MCP.Service
yield* mcp
.add("test-server", {
type: "remote",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer test-token",
"X-Custom-Header": "custom-value",
},
})
.pipe(Effect.catch(() => Effect.void))

// Both transports should have been created with headers
expect(transportCalls.length).toBeGreaterThanOrEqual(1)

for (const call of transportCalls) {
expect(call.options.requestInit).toBeDefined()
expect(call.options.requestInit?.headers).toEqual({
const result = yield* mcp.add("test-server", {
type: "remote",
url: server.url,
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()
},
})

expect(result.status).toMatchObject({ "test-server": { status: "connected" } })
expect(server.requests.length).toBeGreaterThan(0)
for (const headers of server.requests) {
expect(headers.get("authorization")).toBe("Bearer test-token")
expect(headers.get("x-custom-header")).toBe("custom-value")
}
}),
)

it.instance("headers are passed to transports when oauth is explicitly disabled", () =>
Effect.gen(function* () {
const server = yield* serve
const mcp = yield* MCP.Service
yield* mcp
.add("test-server-no-oauth", {
type: "remote",
url: "https://example.com/mcp",
oauth: false,
headers: {
Authorization: "Bearer test-token",
},
})
.pipe(Effect.catch(() => Effect.void))

expect(transportCalls.length).toBeGreaterThanOrEqual(1)

for (const call of transportCalls) {
expect(call.options.requestInit).toBeDefined()
expect(call.options.requestInit?.headers).toEqual({
const result = yield* mcp.add("test-server-no-oauth", {
type: "remote",
url: server.url,
oauth: false,
headers: {
Authorization: "Bearer test-token",
})
// OAuth is disabled, so no authProvider
expect(call.options.authProvider).toBeUndefined()
},
})

expect(result.status).toMatchObject({ "test-server-no-oauth": { status: "connected" } })
expect(server.requests.length).toBeGreaterThan(0)
for (const headers of server.requests) {
expect(headers.get("authorization")).toBe("Bearer test-token")
}
}),
)

it.instance("no requestInit when headers are not provided", () =>
Effect.gen(function* () {
const server = yield* serve
const mcp = yield* MCP.Service
yield* mcp
.add("test-server-no-headers", {
type: "remote",
url: "https://example.com/mcp",
})
.pipe(Effect.catch(() => Effect.void))

expect(transportCalls.length).toBeGreaterThanOrEqual(1)
const result = yield* mcp.add("test-server-no-headers", {
type: "remote",
url: server.url,
})

for (const call of transportCalls) {
// No headers means requestInit should be undefined
expect(call.options.requestInit).toBeUndefined()
expect(result.status).toMatchObject({ "test-server-no-headers": { status: "connected" } })
expect(server.requests.length).toBeGreaterThan(0)
for (const headers of server.requests) {
expect(headers.has("authorization")).toBe(false)
expect(headers.has("x-custom-header")).toBe(false)
}
}),
)
Expand Down
Loading
Loading