diff --git a/packages/opencode/src/mcp/browser.ts b/packages/opencode/src/mcp/browser.ts new file mode 100644 index 000000000000..5760d8cbf9a0 --- /dev/null +++ b/packages/opencode/src/mcp/browser.ts @@ -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 +} + +export class Service extends Context.Service()("@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((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" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 0d6c8c65dcdc..05f12fa2ee45 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -26,7 +26,6 @@ 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" @@ -34,6 +33,7 @@ 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 = { @@ -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 @@ -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((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) }), @@ -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 "." diff --git a/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts b/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts new file mode 100644 index 000000000000..b01ed921cfd4 --- /dev/null +++ b/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts @@ -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()) diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index e6b83d678ec8..31cfc20d51c6 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -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) } }), ) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index aabb171f9396..80c8fd22f886 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,1304 +1,560 @@ import path from "node:path" import { pathToFileURL } from "node:url" -import { expect, mock, beforeEach } from "bun:test" -import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" +import { expect } from "bun:test" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, + type ServerCapabilities, + type Tool, +} from "@modelcontextprotocol/sdk/types.js" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" -import { testEffect } from "../lib/effect" +import { MCP } from "../../src/mcp/index" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" import { TestInstance } from "../fixture/fixture" +import { pollWithTimeout, testEffect } from "../lib/effect" -// --- Mock infrastructure --- +const it = testEffect(LayerNode.compile(MCP.node)) +const stdioFixture = path.join(import.meta.dir, "../fixture/mcp-lifecycle-stdio.ts") + +type Page = { items: T[]; nextCursor?: string } -// Per-client state for controlling mock behavior -interface MockClientState { - capabilities: { tools?: object; prompts?: object; resources?: object } - capabilitiesShouldThrow: boolean - instructions?: string - tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> - listToolsCalls: number - listPromptsCalls: number - listResourcesCalls: number - listResourceTemplatesCalls: number - getPromptTimeout?: number - readResourceTimeout?: number - requestCalls: number - listToolsShouldFail: boolean - listToolsError: string - listPromptsShouldFail: boolean - listResourcesShouldFail: boolean +interface LifecycleServerState { + tools: Tool[] prompts: Array<{ name: string; description?: string }> resources: Array<{ name: string; uri: string; description?: string }> resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }> - toolPages: Record< - string, - { - tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> - nextCursor?: string - } - > - promptPages: Record; nextCursor?: string }> - resourcePages: Record< - string, - { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string } - > - resourceTemplatePages: Record< - string, - { resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>; nextCursor?: string } - > - closed: boolean - clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } } - requestHandlers: Map Promise> - notificationHandlers: Map any> -} - -const clientStates = new Map() -let lastCreatedClientName: string | undefined -let connectShouldFail = false -let connectShouldHang = false -let connectError = "Mock transport cannot connect" -// Tracks how many Client instances were created (detects leaks) -let clientCreateCount = 0 -// Tracks how many times transport.close() is called across all mock transports -let transportCloseCount = 0 -// Captures the opts passed to each MockStdioTransport, keyed by lastCreatedClientName -const stdioOptsByName = new Map() - -function getOrCreateClientState(name?: string): MockClientState { - const key = name ?? "default" - let state = clientStates.get(key) - if (!state) { - state = { - capabilities: { tools: {}, prompts: {}, resources: {} }, - capabilitiesShouldThrow: false, - tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }], - listToolsCalls: 0, - listPromptsCalls: 0, - listResourcesCalls: 0, - listResourceTemplatesCalls: 0, - requestCalls: 0, - listToolsShouldFail: false, - listToolsError: "listTools failed", - listPromptsShouldFail: false, - listResourcesShouldFail: false, - prompts: [], - resources: [], - resourceTemplates: [], - toolPages: {}, - promptPages: {}, - resourcePages: {}, - resourceTemplatePages: {}, - closed: false, - requestHandlers: new Map(), - notificationHandlers: new Map(), - } - clientStates.set(key, state) - } - return state -} - -// Mock transport that succeeds or fails based on connectShouldFail / connectShouldHang -class MockStdioTransport { - stderr: null = null - pid = 12345 - constructor(opts: any) { - if (lastCreatedClientName) stdioOptsByName.set(lastCreatedClientName, opts) - } - async start() { - if (connectShouldHang) return new Promise(() => {}) // never resolves - if (connectShouldFail) throw new Error(connectError) - } - async close() { - transportCloseCount++ - } -} - -class MockStreamableHTTP { - // oxlint-disable-next-line no-useless-constructor - constructor(_url: URL, _opts?: any) {} - async start() { - if (connectShouldHang) return new Promise(() => {}) // never resolves - if (connectShouldFail) throw new Error(connectError) - } - async close() { - transportCloseCount++ - } - async finishAuth() {} -} - -class MockSSE { - // oxlint-disable-next-line no-useless-constructor - constructor(_url: URL, _opts?: any) {} - async start() { - if (connectShouldHang) return new Promise(() => {}) // never resolves - if (connectShouldFail) throw new Error(connectError) - } - async close() { - transportCloseCount++ - } + toolPages?: Record> + promptPages?: Record> + resourcePages?: Record> + resourceTemplatePages?: Record> + listToolsError?: string + requestDelay?: number + roots?: Array<{ uri: string; name?: string }> + requests: string[] + aborted: number } -void mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({ - StdioClientTransport: MockStdioTransport, -})) - -void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ - StreamableHTTPClientTransport: MockStreamableHTTP, -})) - -void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ - SSEClientTransport: MockSSE, -})) - -void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ - UnauthorizedError: class extends Error { - constructor() { - super("Unauthorized") - } - }, -})) - -// Mock Client that delegates to per-name MockClientState -void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ - Client: class MockClient { - _state!: MockClientState - transport: any - - constructor(_info: any, options?: MockClientState["clientOptions"]) { - clientCreateCount++ - this._state = getOrCreateClientState(lastCreatedClientName) - this._state.clientOptions = options - } - - async connect(transport: { start: () => Promise }) { - this.transport = transport - await transport.start() - // After successful connect, bind to the last-created client name - this._state = getOrCreateClientState(lastCreatedClientName) - } - - setRequestHandler(schema: unknown, handler: (...args: any[]) => Promise) { - this._state.requestHandlers.set(schema, handler) - } - - setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) { - this._state?.notificationHandlers.set(schema, handler) - } - - getServerCapabilities() { - if (this._state?.capabilitiesShouldThrow) throw new Error("capability discovery failed") - return this._state?.capabilities - } - - getInstructions() { - return this._state?.instructions - } - - async listTools(params?: { cursor?: string }) { - if (this._state) this._state.listToolsCalls++ - if (this._state?.listToolsShouldFail) { - throw new Error(this._state.listToolsError) +function lifecycleServer(input?: { capabilities?: ServerCapabilities; instructions?: string; requestRoots?: boolean }) { + const capabilities = input?.capabilities ?? { tools: {}, prompts: {}, resources: {} } + return Effect.acquireRelease( + Effect.promise(async () => { + const state: LifecycleServerState = { + tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }], + prompts: [], + resources: [], + resourceTemplates: [], + requests: [], + aborted: 0, } - const page = this._state?.toolPages[params === undefined ? "initial" : (params.cursor ?? "")] - if (page) return page - return { tools: this._state?.tools ?? [] } - } - async request( - request: { method: string; params?: { cursor?: string } }, - schema: { parse: (value: unknown) => unknown }, - ) { - if (this._state) this._state.requestCalls++ - if (request.method === "tools/list") { - return schema.parse( - this._state?.toolPages[request.params === undefined ? "initial" : (request.params.cursor ?? "")] ?? { - tools: this._state?.tools ?? [], - }, + const makeProtocol = async () => { + const protocol = new Server( + { name: "mcp-lifecycle", version: "1.0.0" }, + { capabilities, instructions: input?.instructions }, ) - } - throw new Error(`unsupported request: ${request.method}`) - } - - async listPrompts(params?: { cursor?: string }) { - if (this._state) this._state.listPromptsCalls++ - if (this._state?.listPromptsShouldFail) { - throw new Error("listPrompts failed") - } - const page = this._state?.promptPages[params === undefined ? "initial" : (params.cursor ?? "")] - if (page) return page - return { prompts: this._state?.prompts ?? [] } - } - - async listResources(params?: { cursor?: string }) { - if (this._state) this._state.listResourcesCalls++ - if (this._state?.listResourcesShouldFail) { - throw new Error("listResources failed") - } - const page = this._state?.resourcePages[params === undefined ? "initial" : (params.cursor ?? "")] - if (page) return page - return { resources: this._state?.resources ?? [] } - } - - async listResourceTemplates(params?: { cursor?: string }) { - if (this._state) this._state.listResourceTemplatesCalls++ - const page = this._state?.resourceTemplatePages[params === undefined ? "initial" : (params.cursor ?? "")] - if (page) return page - return { resourceTemplates: this._state?.resourceTemplates ?? [] } - } - - async getPrompt(_params: unknown, options?: { timeout?: number }) { - if (this._state) this._state.getPromptTimeout = options?.timeout - return { messages: [] } - } - - async readResource(params: { uri: string }, options?: { timeout?: number }) { - if (this._state) this._state.readResourceTimeout = options?.timeout - return { contents: [{ uri: params.uri, text: "test" }] } - } - - async close() { - if (this._state) this._state.closed = true - } - }, -})) - -beforeEach(() => { - clientStates.clear() - lastCreatedClientName = undefined - connectShouldFail = false - connectShouldHang = false - connectError = "Mock transport cannot connect" - clientCreateCount = 0 - transportCloseCount = 0 -}) - -// Import after mocks -const { MCP } = await import("../../src/mcp/index") -const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") - -const it = testEffect(LayerNode.compile(MCP.node)) - -function statusName(status: Record | MCPNS.Status, server: string) { - if ("status" in status) return status.status - return status[server]?.status -} - -it.instance( - "advertises and lists the instance directory as its root", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - const { directory } = yield* TestInstance - lastCreatedClientName = "roots" - yield* mcp.add("roots", { type: "local", command: ["echo", "test"] }) - - const state = getOrCreateClientState("roots") - expect(state.clientOptions?.capabilities?.roots).toEqual({}) - expect(state.clientOptions?.capabilities?.roots?.listChanged).toBeUndefined() - - const handler = state.requestHandlers.get(ListRootsRequestSchema) - expect(handler).toBeDefined() - const result = yield* Effect.promise(() => handler?.() ?? Promise.reject(new Error("roots handler missing"))) - expect(result).toEqual({ roots: [{ uri: pathToFileURL(directory).href }] }) - }), - ), - { config: { mcp: {} } }, -) - -it.instance( - "local mcp cwd resolves relative paths against instance directory", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - const { directory } = yield* TestInstance - lastCreatedClientName = "rel-cwd" - yield* mcp.add("rel-cwd", { type: "local", command: ["echo", "test"], cwd: "plugins/sub" }) - expect(stdioOptsByName.get("rel-cwd")?.cwd).toBe(path.resolve(directory, "plugins/sub")) - }), - ), - { config: { mcp: {} } }, -) - -// ======================================================================== -// Test: tools() are cached after connect -// ======================================================================== - -it.instance( - "tools() reuses cached tool definitions after connect", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "my-server" - const serverState = getOrCreateClientState("my-server") - serverState.tools = [ - { name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } }, - ] - - // First: add the server successfully - const addResult = yield* mcp.add("my-server", { - type: "local", - command: ["echo", "test"], - }) - expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected") - - expect(serverState.listToolsCalls).toBe(1) - - const toolsA = yield* mcp.tools() - const toolsB = yield* mcp.tools() - expect(Object.keys(toolsA).length).toBeGreaterThan(0) - expect(Object.keys(toolsB).length).toBeGreaterThan(0) - expect(serverState.listToolsCalls).toBe(1) - }), - ), - { config: { mcp: {} } }, -) - -it.instance( - "instructions() returns connected server instructions with tool names", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "guide-server" - const serverState = getOrCreateClientState("guide-server") - serverState.instructions = "Use lookup before mutate." - - yield* mcp.add("guide-server", { - type: "local", - command: ["echo", "test"], + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, }) - expect(yield* mcp.instructions()).toContainEqual({ - name: "guide-server", - instructions: "Use lookup before mutate.", - tools: ["guide-server_test_tool"], - }) - }), - ), - { config: { mcp: {} } }, -) - -it.instance( - "instructions() omits empty and disconnected server instructions", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "temporary-server" - getOrCreateClientState("temporary-server").instructions = "Temporary guidance." - - yield* mcp.add("temporary-server", { - type: "local", - command: ["echo", "test"], - }) - yield* mcp.disconnect("temporary-server") - - lastCreatedClientName = "blank-server" - getOrCreateClientState("blank-server").instructions = " " - - yield* mcp.add("blank-server", { - type: "local", - command: ["echo", "test"], - }) - - const instructions = yield* mcp.instructions() - expect(instructions.some((item) => item.name === "temporary-server")).toBe(false) - expect(instructions.some((item) => item.name === "blank-server")).toBe(false) - }), - ), - { config: { mcp: {} } }, -) - -it.instance( - "follows cursors when listing tools, prompts, and resources", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "paged-server" - const serverState = getOrCreateClientState("paged-server") - serverState.toolPages = { - initial: { - tools: [{ name: "tool-one", inputSchema: { type: "object", properties: {} } }], - nextCursor: "tools-2", - }, - "tools-2": { tools: [{ name: "tool-two", inputSchema: { type: "object", properties: {} } }] }, + if (capabilities.tools) { + protocol.setRequestHandler(ListToolsRequestSchema, (request) => { + if (state.listToolsError) throw new Error(state.listToolsError) + const page = state.toolPages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ tools: page?.items ?? state.tools, nextCursor: page?.nextCursor }) + }) } - serverState.promptPages = { - initial: { prompts: [{ name: "prompt-one" }], nextCursor: "prompts-2" }, - "prompts-2": { prompts: [{ name: "prompt-two" }] }, + if (capabilities.prompts) { + protocol.setRequestHandler(ListPromptsRequestSchema, (request) => { + const page = state.promptPages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ prompts: page?.items ?? state.prompts, nextCursor: page?.nextCursor }) + }) + protocol.setRequestHandler(GetPromptRequestSchema, async () => { + if (state.requestDelay) await Bun.sleep(state.requestDelay) + return { messages: [{ role: "user", content: { type: "text", text: "prompt result" } }] } + }) } - serverState.resourcePages = { - initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" }, - "resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] }, + if (capabilities.resources) { + protocol.setRequestHandler(ListResourcesRequestSchema, (request) => { + const page = state.resourcePages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor }) + }) + protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => { + const page = state.resourceTemplatePages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ + resourceTemplates: page?.items ?? state.resourceTemplates, + nextCursor: page?.nextCursor, + }) + }) + protocol.setRequestHandler(ReadResourceRequestSchema, async (request) => { + if (state.requestDelay) await Bun.sleep(state.requestDelay) + return { contents: [{ uri: request.params.uri, text: "resource result" }] } + }) } - serverState.resourceTemplatePages = { - initial: { - resourceTemplates: [{ name: "template-one", uriTemplate: "test://one/{id}" }], - nextCursor: "resource-templates-2", - }, - "resource-templates-2": { resourceTemplates: [{ name: "template-two", uriTemplate: "test://two/{id}" }] }, - } - - yield* mcp.add("paged-server", { - type: "local", - command: ["echo", "test"], - }) - - expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"]) - expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"]) - expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"]) - expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([ - "paged-server:test://one/{id}", - "paged-server:test://two/{id}", - ]) - expect(serverState.listToolsCalls).toBe(2) - expect(serverState.listPromptsCalls).toBe(2) - expect(serverState.listResourcesCalls).toBe(2) - expect(serverState.listResourceTemplatesCalls).toBe(2) - }), - ), - { config: { mcp: {} } }, -) -it.instance( - "stops listing when a server repeats a cursor", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "looping-server" - const serverState = getOrCreateClientState("looping-server") - serverState.toolPages = { - initial: { tools: [], nextCursor: "repeat" }, - repeat: { tools: [], nextCursor: "repeat" }, - } - - yield* mcp.add("looping-server", { - type: "local", - command: ["echo", "test"], - }) - - expect(serverState.listToolsCalls).toBe(2) - expect(yield* mcp.tools()).toEqual({}) - }), - ), - { config: { mcp: {} } }, -) - -it.instance( - "follows empty cursors", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "empty-cursor-server" - const serverState = getOrCreateClientState("empty-cursor-server") - serverState.promptPages = { - initial: { prompts: [{ name: "prompt-one" }], nextCursor: "" }, - "": { prompts: [{ name: "prompt-two" }] }, + protocol.oninitialized = () => { + if (!input?.requestRoots) return + if (!protocol.getClientCapabilities()?.roots) return + void Bun.sleep(25) + .then(() => protocol.listRoots()) + .then((result) => { + state.roots = result.roots + }) + .catch(() => {}) } + await protocol.connect(transport) + return { protocol, transport } + } - yield* mcp.add("empty-cursor-server", { - type: "local", - command: ["echo", "test"], - }) - - expect(Object.keys(yield* mcp.prompts())).toEqual([ - "empty-cursor-server:prompt-one", - "empty-cursor-server:prompt-two", - ]) - expect(serverState.listPromptsCalls).toBe(2) - }), - ), - { config: { mcp: {} } }, -) - -// ======================================================================== -// Test: tool change notifications refresh the cache -// ======================================================================== - -it.instance( - "tool change notifications refresh cached tool definitions", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "status-server" - const serverState = getOrCreateClientState("status-server") - - yield* mcp.add("status-server", { - type: "local", - command: ["echo", "test"], - }) - - const before = yield* mcp.tools() - expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true) - expect(serverState.listToolsCalls).toBe(1) - - serverState.tools = [ - { name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }, - ] - - const handler = serverState.notificationHandlers.get(ToolListChangedNotificationSchema) - expect(handler).toBeDefined() - yield* Effect.promise(() => handler?.()) - - const after = yield* mcp.tools() - expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true) - expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false) - expect(serverState.listToolsCalls).toBe(2) - }), - ), - { config: { mcp: {} } }, -) - -// ======================================================================== -// Test: connect() / disconnect() lifecycle -// ======================================================================== - -it.instance( - "disconnect sets status to disabled and removes client", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "disc-server" - getOrCreateClientState("disc-server") - - yield* mcp.add("disc-server", { - type: "local", - command: ["echo", "test"], - }) - - const statusBefore = yield* mcp.status() - expect(statusBefore["disc-server"]?.status).toBe("connected") - - yield* mcp.disconnect("disc-server") - - const statusAfter = yield* mcp.status() - expect(statusAfter["disc-server"]?.status).toBe("disabled") - - const tools = yield* mcp.tools() - const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server")) - expect(serverTools.length).toBe(0) - }), - ), - { - config: { - mcp: { - "disc-server": { - type: "local", - command: ["echo", "test"], + let current = await makeProtocol() + const http = Bun.serve({ + port: 0, + fetch(request) { + state.requests.push(request.method) + request.signal.addEventListener("abort", () => state.aborted++) + return current.transport.handleRequest(request) }, - }, - }, - }, -) - -it.instance( - "connect() after disconnect() re-establishes the server", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "reconn-server" - const serverState = getOrCreateClientState("reconn-server") - serverState.tools = [ - { name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } }, - ] - - yield* mcp.add("reconn-server", { - type: "local", - command: ["echo", "test"], - }) - - yield* mcp.disconnect("reconn-server") - expect((yield* mcp.status())["reconn-server"]?.status).toBe("disabled") - - yield* mcp.connect("reconn-server") - expect((yield* mcp.status())["reconn-server"]?.status).toBe("connected") - - const tools = yield* mcp.tools() - expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true) - }), - ), - { - config: { - mcp: { - "reconn-server": { - type: "local", - command: ["echo", "test"], + }) + + return { + state, + url: http.url.toString(), + sendToolListChanged: () => current.protocol.sendToolListChanged(), + restart: async () => { + current = await makeProtocol() }, - }, - }, - }, -) - -// ======================================================================== -// Test: add() closes existing client before replacing -// ======================================================================== - -it.instance( - "add() closes the old client when replacing a server", - // Don't put the server in config — add it dynamically so we control - // exactly which client instance is "first" vs "second". - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "replace-server" - const firstState = getOrCreateClientState("replace-server") - - yield* mcp.add("replace-server", { - type: "local", - command: ["echo", "test"], - }) - - expect(firstState.closed).toBe(false) - - // Create new state for second client - clientStates.delete("replace-server") - const secondState = getOrCreateClientState("replace-server") - - // Re-add should close the first client - yield* mcp.add("replace-server", { - type: "local", - command: ["echo", "test"], - }) - - expect(firstState.closed).toBe(true) - expect(secondState.closed).toBe(false) - }), - ), - { config: { mcp: {} } }, -) - -// ======================================================================== -// Test: state init with mixed success/failure -// ======================================================================== - -it.instance( - "init connects available servers even when one fails", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - // Set up good server - const goodState = getOrCreateClientState("good-server") - goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }] - - // Set up bad server - will fail on listTools during create() - const badState = getOrCreateClientState("bad-server") - badState.listToolsShouldFail = true - - // Add good server first - lastCreatedClientName = "good-server" - yield* mcp.add("good-server", { - type: "local", - command: ["echo", "good"], - }) - - // Add bad server - should fail but not affect good server - lastCreatedClientName = "bad-server" - yield* mcp.add("bad-server", { - type: "local", - command: ["echo", "bad"], - }) - - const status = yield* mcp.status() - expect(status["good-server"]?.status).toBe("connected") - expect(status["bad-server"]?.status).toBe("failed") - - // Good server's tools should still be available - const tools = yield* mcp.tools() - expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true) - }), - ), - { - config: { - mcp: { - "good-server": { - type: "local", - command: ["echo", "good"], - }, - "bad-server": { - type: "local", - command: ["echo", "bad"], + close: async () => { + await current.protocol.close().catch(() => {}) + http.stop(true) }, - }, - }, - }, -) - -it.instance( - "returns failed and closes the client when SDK initialization throws", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "defective-server" - const serverState = getOrCreateClientState("defective-server") - serverState.capabilitiesShouldThrow = true - - const result = yield* mcp.add("defective-server", { - type: "local", - command: ["echo", "test"], - }) - - expect(statusName(result.status, "defective-server")).toBe("failed") - expect((yield* mcp.status())["defective-server"]).toEqual({ - status: "failed", - error: "capability discovery failed", - }) - expect(serverState.closed).toBe(true) - }), - ), - { config: { mcp: {} } }, -) - -it.instance( - "falls back when MCP output schema refs fail SDK tool discovery", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "stitch-like-server" - const serverState = getOrCreateClientState("stitch-like-server") - serverState.listToolsShouldFail = true - serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #" - serverState.tools = [ - { - name: "render_screen", - description: "renders a screen", - inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }, - outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } }, - }, - ] - - const addResult = yield* mcp.add("stitch-like-server", { - type: "local", - command: ["echo", "test"], - }) - - expect(statusName(addResult.status, "stitch-like-server")).toBe("connected") - - const tools = yield* mcp.tools() - expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true) - expect(serverState.listToolsCalls).toBe(1) - expect(serverState.requestCalls).toBe(1) - }), - ), - { config: { mcp: {} } }, -) - -it.instance( - "does not fall back for non-schema MCP tool discovery errors", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "broken-server" - const serverState = getOrCreateClientState("broken-server") - serverState.listToolsShouldFail = true - serverState.listToolsError = "transport closed" - - const addResult = yield* mcp.add("broken-server", { - type: "local", - command: ["echo", "test"], - }) - - expect(statusName(addResult.status, "broken-server")).toBe("failed") - expect(serverState.listToolsCalls).toBe(1) - expect(serverState.requestCalls).toBe(0) - }), - ), - { config: { mcp: {} } }, -) - -// ======================================================================== -// Test: disabled server via config -// ======================================================================== - -it.instance( - "disabled server is marked as disabled without attempting connection", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - const countBefore = clientCreateCount - - yield* mcp.add("disabled-server", { - type: "local", - command: ["echo", "test"], - enabled: false, - } as any) - - // No client should have been created - expect(clientCreateCount).toBe(countBefore) - - const status = yield* mcp.status() - expect(status["disabled-server"]?.status).toBe("disabled") - }), - ), - { - config: { - mcp: { - "disabled-server": { - type: "local", - command: ["echo", "test"], - enabled: false, - }, - }, - }, - }, -) - -// ======================================================================== -// Test: prompts() and resources() -// ======================================================================== - -it.instance( - "prompts() returns prompts from connected servers", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "prompt-server" - const serverState = getOrCreateClientState("prompt-server") - serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }] - - yield* mcp.add("prompt-server", { - type: "local", - command: ["echo", "test"], - }) + } + }), + (server) => Effect.promise(server.close), + ) +} - const prompts = yield* mcp.prompts() - expect(Object.keys(prompts).length).toBe(1) - const key = Object.keys(prompts)[0] - expect(key).toContain("prompt-server") - expect(key).toContain("my-prompt") - }), - ), - { - config: { - mcp: { - "prompt-server": { - type: "local", - command: ["echo", "test"], +function hangingLifecycleServer() { + return Effect.acquireRelease( + Effect.promise(async () => { + const protocol = new Server({ name: "mcp-lifecycle-hanging", 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 requests: string[] = [] + let aborted = 0 + const http = Bun.serve({ + port: 0, + fetch(request) { + requests.push(request.method) + request.signal.addEventListener("abort", () => aborted++) + return new Promise(() => {}) }, - }, - }, - }, -) - -it.instance( - "resources() returns resources from connected servers", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "resource-server" - const serverState = getOrCreateClientState("resource-server") - serverState.resources = [ - { name: "my-resource", uri: "file:///test.txt", description: "A test resource" }, - { name: "my-resource", uri: "ui://component-state", description: "A second resource with same name" }, - ] - - yield* mcp.add("resource-server", { - type: "local", - command: ["echo", "test"], - }) - - const resources = yield* mcp.resources() - expect(Object.keys(resources)).toEqual([ - "resource-server:file:///test.txt", - "resource-server:ui://component-state", - ]) - }), - ), - { - config: { - mcp: { - "resource-server": { - type: "local", - command: ["echo", "test"], + }) + return { + requests, + aborted: () => aborted, + url: http.url.toString(), + close: async () => { + await protocol.close().catch(() => {}) + http.stop(true) }, - }, - }, - }, -) - -it.instance( - "uses per-server timeouts for prompt and resource requests", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "timeout-server" - const serverState = getOrCreateClientState("timeout-server") - - yield* mcp.add("timeout-server", { - type: "local", - command: ["echo", "test"], - timeout: 2500, - }) - yield* mcp.getPrompt("timeout-server", "test") - yield* mcp.readResource("timeout-server", "test://resource") - - expect(serverState.getPromptTimeout).toBe(2500) - expect(serverState.readResourceTimeout).toBe(2500) - }), - ), - { config: { mcp: {}, experimental: { mcp_timeout: 5000 } } }, -) - -it.instance( - "resource-only servers connect without listing tools", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "resource-only-server" - const serverState = getOrCreateClientState("resource-only-server") - serverState.capabilities = { resources: {} } - serverState.resources = [{ name: "docs", uri: "docs://readme" }] - - const result = yield* mcp.add("resource-only-server", { - type: "local", - command: ["echo", "test"], - }) - - expect(statusName(result.status, "resource-only-server")).toBe("connected") - expect(serverState.listToolsCalls).toBe(0) - expect(Object.keys(yield* mcp.tools())).toHaveLength(0) - expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs://readme"]) - expect(serverState.listResourcesCalls).toBe(1) - expect(serverState.listPromptsCalls).toBe(0) - }), - ), - { config: { mcp: {} } }, -) - -it.instance( - "prompt-only servers connect without listing tools", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "prompt-only-server" - const serverState = getOrCreateClientState("prompt-only-server") - serverState.capabilities = { prompts: {} } - serverState.prompts = [{ name: "review" }] - - const result = yield* mcp.add("prompt-only-server", { - type: "local", - command: ["echo", "test"], - }) + } + }), + (server) => Effect.promise(server.close), + ) +} - expect(statusName(result.status, "prompt-only-server")).toBe("connected") - expect(serverState.listToolsCalls).toBe(0) - expect(Object.keys(yield* mcp.tools())).toHaveLength(0) - expect(Object.keys(yield* mcp.prompts())).toEqual(["prompt-only-server:review"]) - expect(serverState.listPromptsCalls).toBe(1) - expect(serverState.listResourcesCalls).toBe(0) - }), - ), - { config: { mcp: {} } }, -) +function statusName(status: Record | MCPNS.Status, server: string) { + if ("status" in status) return status.status + return status[server]?.status +} -it.instance( - "tools-only servers skip optional prompt and resource discovery", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "tools-only-server" - const serverState = getOrCreateClientState("tools-only-server") - serverState.capabilities = { tools: {} } +const remote = (url: string, timeout?: number) => ({ type: "remote" as const, url, oauth: false as const, timeout }) - const result = yield* mcp.add("tools-only-server", { - type: "local", - command: ["echo", "test"], - }) +it.instance("advertises and lists the instance directory as its root", () => + Effect.gen(function* () { + const server = yield* lifecycleServer({ requestRoots: true }) + const mcp = yield* MCP.Service + const test = yield* TestInstance + yield* mcp.add("roots", remote(server.url)) - expect(statusName(result.status, "tools-only-server")).toBe("connected") - expect(serverState.listToolsCalls).toBe(1) - expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only-server_test_tool"]) - expect(yield* mcp.prompts()).toEqual({}) - expect(yield* mcp.resources()).toEqual({}) - expect(serverState.listPromptsCalls).toBe(0) - expect(serverState.listResourcesCalls).toBe(0) - }), - ), - { config: { mcp: {} } }, + const roots = yield* pollWithTimeout( + Effect.sync(() => server.state.roots), + "server did not receive roots", + ) + expect(roots).toEqual([{ uri: pathToFileURL(test.directory).href }]) + }), ) it.instance( - "prompts() skips disconnected servers", + "local mcp cwd resolves relative paths against instance directory", () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "prompt-disc-server" - const serverState = getOrCreateClientState("prompt-disc-server") - serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }] - - yield* mcp.add("prompt-disc-server", { - type: "local", - command: ["echo", "test"], - }) - - yield* mcp.disconnect("prompt-disc-server") - - const prompts = yield* mcp.prompts() - expect(Object.keys(prompts).length).toBe(0) - }), - ), - { - config: { - mcp: { - "prompt-disc-server": { - type: "local", - command: ["echo", "test"], - }, + Effect.gen(function* () { + const mcp = yield* MCP.Service + const test = yield* TestInstance + yield* mcp.add("rel-cwd", { + type: "local", + command: [process.execPath, stdioFixture], + cwd: "plugins/sub", + }) + + expect((yield* mcp.tools())["rel-cwd_current_directory"]?.def.description).toBe( + path.resolve(test.directory, "plugins/sub"), + ) + }), + { init: (directory) => Effect.promise(() => Bun.$`mkdir -p ${path.join(directory, "plugins/sub")}`.quiet()) }, +) + +it.instance("tools() reuses cached definitions until a protocol notification", () => + Effect.gen(function* () { + const server = yield* lifecycleServer({ capabilities: { tools: { listChanged: true } } }) + const mcp = yield* MCP.Service + yield* mcp.add("cache-server", remote(server.url)) + server.state.tools = [{ name: "next_tool", inputSchema: { type: "object", properties: {} } }] + + expect(Object.keys(yield* mcp.tools())).toEqual(["cache-server_test_tool"]) + yield* Effect.promise(server.sendToolListChanged) + yield* pollWithTimeout( + Effect.gen(function* () { + const keys = Object.keys(yield* mcp.tools()) + return keys.includes("cache-server_next_tool") ? keys : undefined + }), + "tool cache did not refresh", + ) + expect(Object.keys(yield* mcp.tools())).toEqual(["cache-server_next_tool"]) + }), +) + +it.instance("instructions() returns non-empty connected server instructions with tool names", () => + Effect.gen(function* () { + const guide = yield* lifecycleServer({ instructions: "Use lookup before mutate." }) + const blank = yield* lifecycleServer({ instructions: " " }) + const mcp = yield* MCP.Service + yield* mcp.add("guide-server", remote(guide.url)) + yield* mcp.add("blank-server", remote(blank.url)) + + expect(yield* mcp.instructions()).toEqual([ + { name: "guide-server", instructions: "Use lookup before mutate.", tools: ["guide-server_test_tool"] }, + ]) + yield* mcp.disconnect("guide-server") + expect(yield* mcp.instructions()).toEqual([]) + }), +) + +it.instance("follows cursors when listing tools, prompts, resources, and templates", () => + Effect.gen(function* () { + const server = yield* lifecycleServer() + server.state.toolPages = { + initial: { items: [{ name: "tool-one", inputSchema: { type: "object" } }], nextCursor: "tools-2" }, + "tools-2": { items: [{ name: "tool-two", inputSchema: { type: "object" } }] }, + } + server.state.promptPages = { + initial: { items: [{ name: "prompt-one" }], nextCursor: "prompts-2" }, + "prompts-2": { items: [{ name: "prompt-two" }] }, + } + server.state.resourcePages = { + initial: { items: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" }, + "resources-2": { items: [{ name: "resource-two", uri: "test://two" }] }, + } + server.state.resourceTemplatePages = { + initial: { items: [{ name: "template-one", uriTemplate: "test://one/{id}" }], nextCursor: "templates-2" }, + "templates-2": { items: [{ name: "template-two", uriTemplate: "test://two/{id}" }] }, + } + const mcp = yield* MCP.Service + yield* mcp.add("paged-server", remote(server.url)) + + expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"]) + expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"]) + expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([ + "paged-server:test://one/{id}", + "paged-server:test://two/{id}", + ]) + }), +) + +it.instance("accepts empty cursors and rejects repeated cursors", () => + Effect.gen(function* () { + const empty = yield* lifecycleServer({ capabilities: { prompts: {} } }) + empty.state.promptPages = { + initial: { items: [{ name: "prompt-one" }], nextCursor: "" }, + "": { items: [{ name: "prompt-two" }] }, + } + const looping = yield* lifecycleServer({ capabilities: { tools: {} } }) + looping.state.toolPages = { + initial: { items: [], nextCursor: "repeat" }, + repeat: { items: [], nextCursor: "repeat" }, + } + const mcp = yield* MCP.Service + yield* mcp.add("empty-cursor", remote(empty.url)) + const result = yield* mcp.add("looping-cursor", remote(looping.url)) + + expect(Object.keys(yield* mcp.prompts())).toEqual(["empty-cursor:prompt-one", "empty-cursor:prompt-two"]) + expect(statusName(result.status, "looping-cursor")).toBe("failed") + }), +) + +it.instance("disconnect removes protocol data and reconnect establishes a new session", () => + Effect.gen(function* () { + const server = yield* lifecycleServer() + const mcp = yield* MCP.Service + yield* mcp.add("reconnect-server", remote(server.url)) + expect((yield* mcp.status())["reconnect-server"]?.status).toBe("connected") + + yield* mcp.disconnect("reconnect-server") + expect((yield* mcp.status())["reconnect-server"]?.status).toBe("disabled") + expect(Object.keys(yield* mcp.tools())).toEqual([]) + yield* pollWithTimeout( + Effect.sync(() => (server.state.aborted > 0 ? server.state.aborted : undefined)), + "disconnected HTTP session was not aborted", + ) + + yield* Effect.promise(server.restart) + yield* mcp.connect("reconnect-server") + expect((yield* mcp.status())["reconnect-server"]?.status).toBe("connected") + expect(Object.keys(yield* mcp.tools())).toEqual(["reconnect-server_test_tool"]) + }), +) + +it.instance("add() closes the old protocol session when replacing a server", () => + Effect.gen(function* () { + const first = yield* lifecycleServer() + const second = yield* lifecycleServer() + const mcp = yield* MCP.Service + yield* mcp.add("replace-server", remote(first.url)) + yield* mcp.add("replace-server", remote(second.url)) + + yield* pollWithTimeout( + Effect.sync(() => (first.state.aborted > 0 ? first.state.aborted : undefined)), + "replaced HTTP session was not aborted", + ) + expect(second.state.aborted).toBe(0) + expect(Object.keys(yield* mcp.tools())).toEqual(["replace-server_test_tool"]) + }), +) + +it.instance("one failed server does not affect another connected server", () => + Effect.gen(function* () { + const good = yield* lifecycleServer() + good.state.tools = [{ name: "good_tool", inputSchema: { type: "object" } }] + const bad = yield* lifecycleServer() + bad.state.listToolsError = "listTools failed" + const mcp = yield* MCP.Service + yield* mcp.add("good-server", remote(good.url)) + yield* mcp.add("bad-server", remote(bad.url)) + + expect((yield* mcp.status())["good-server"]?.status).toBe("connected") + expect((yield* mcp.status())["bad-server"]?.status).toBe("failed") + expect(Object.keys(yield* mcp.tools())).toEqual(["good-server_good_tool"]) + }), +) + +it.instance("falls back when output schema refs fail SDK tool discovery", () => + Effect.gen(function* () { + const server = yield* lifecycleServer({ capabilities: { tools: {} } }) + server.state.tools = [ + { + name: "render_screen", + inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }, + outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } }, }, - }, - }, -) - -// ======================================================================== -// Test: connect() on nonexistent server -// ======================================================================== - -it.instance( - "connect() on nonexistent server fails with NotFoundError", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - const exit = yield* mcp.connect("nonexistent").pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "nonexistent" }) - } - const status = yield* mcp.status() - expect(status["nonexistent"]).toBeUndefined() - }), - ), - { config: { mcp: {} } }, -) - -// ======================================================================== -// Test: disconnect() on nonexistent server -// ======================================================================== - -it.instance( - "disconnect() on nonexistent server fails with NotFoundError", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - const exit = yield* mcp.disconnect("nonexistent").pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "nonexistent" }) + ] + const mcp = yield* MCP.Service + const result = yield* mcp.add("schema-server", remote(server.url)) + + expect(statusName(result.status, "schema-server")).toBe("connected") + expect(Object.keys(yield* mcp.tools())).toEqual(["schema-server_render_screen"]) + }), +) + +it.instance("does not fall back for protocol tool discovery errors", () => + Effect.gen(function* () { + const server = yield* lifecycleServer({ capabilities: { tools: {} } }) + server.state.listToolsError = "transport closed" + const mcp = yield* MCP.Service + const result = yield* mcp.add("broken-server", remote(server.url)) + + expect(statusName(result.status, "broken-server")).toBe("failed") + }), +) + +it.instance("disabled server is marked disabled without opening a protocol session", () => + Effect.gen(function* () { + const server = yield* lifecycleServer() + const mcp = yield* MCP.Service + yield* mcp.add("disabled-server", { ...remote(server.url), enabled: false }) + + expect((yield* mcp.status())["disabled-server"]?.status).toBe("disabled") + expect(server.state.requests).toEqual([]) + }), +) + +it.instance("returns prompts and URI-keyed resources from connected servers", () => + Effect.gen(function* () { + const server = yield* lifecycleServer() + server.state.prompts = [{ name: "my-prompt", description: "A test prompt" }] + server.state.resources = [ + { name: "same-name", uri: "file:///test.txt" }, + { name: "same-name", uri: "ui://component-state" }, + ] + const mcp = yield* MCP.Service + yield* mcp.add("content-server", remote(server.url)) + + expect(Object.keys(yield* mcp.prompts())).toEqual(["content-server:my-prompt"]) + expect(Object.keys(yield* mcp.resources())).toEqual([ + "content-server:file:///test.txt", + "content-server:ui://component-state", + ]) + yield* mcp.disconnect("content-server") + expect(yield* mcp.prompts()).toEqual({}) + }), +) + +it.instance("uses per-server timeouts for prompt and resource requests", () => + Effect.gen(function* () { + const server = yield* lifecycleServer() + server.state.requestDelay = 200 + const mcp = yield* MCP.Service + yield* mcp.add("timeout-server", remote(server.url, 50)) + + expect(yield* mcp.getPrompt("timeout-server", "test")).toBeUndefined() + expect(yield* mcp.readResource("timeout-server", "test://resource")).toBeUndefined() + }), +) + +it.instance("connects resource-only, prompt-only, and tools-only servers", () => + Effect.gen(function* () { + const resources = yield* lifecycleServer({ capabilities: { resources: {} } }) + resources.state.resources = [{ name: "docs", uri: "docs://readme" }] + const prompts = yield* lifecycleServer({ capabilities: { prompts: {} } }) + prompts.state.prompts = [{ name: "review" }] + const tools = yield* lifecycleServer({ capabilities: { tools: {} } }) + const mcp = yield* MCP.Service + yield* mcp.add("resource-only", remote(resources.url)) + yield* mcp.add("prompt-only", remote(prompts.url)) + yield* mcp.add("tools-only", remote(tools.url)) + + expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only_test_tool"]) + expect(Object.keys(yield* mcp.prompts())).toEqual(["prompt-only:review"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only:docs://readme"]) + }), +) + +it.instance("connect and disconnect fail for unknown servers", () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + for (const operation of [mcp.connect("missing"), mcp.disconnect("missing")]) { + const exit = yield* operation.pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "missing" }) + } + } + expect(yield* mcp.status()).toEqual({}) + expect(yield* mcp.tools()).toEqual({}) + }), +) + +it.instance("unavailable remote server is marked failed without tools", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.sync(() => Bun.serve({ port: 0, fetch: () => new Response("unavailable", { status: 503 }) })), + (http) => Effect.promise(() => http.stop(true)), + ) + const mcp = yield* MCP.Service + yield* mcp.add("unavailable", remote(server.url.toString(), 500)) + + expect((yield* mcp.status()).unavailable?.status).toBe("failed") + expect(yield* mcp.tools()).toEqual({}) + }), +) + +it.instance("tools() prefixes sanitized server and tool names", () => + Effect.gen(function* () { + const server = yield* lifecycleServer({ capabilities: { tools: {} } }) + server.state.tools = [ + { name: "tool-a", inputSchema: { type: "object" } }, + { name: "tool.b", inputSchema: { type: "object" } }, + ] + const mcp = yield* MCP.Service + yield* mcp.add("my.special-server", remote(server.url)) + + expect(Object.keys(yield* mcp.tools())).toEqual(["my_special-server_tool-a", "my_special-server_tool_b"]) + }), +) + +it.instance("local stdio timeout terminates the real server process", () => + Effect.gen(function* () { + const test = yield* TestInstance + const pidFile = path.join(test.directory, "mcp.pid") + const mcp = yield* MCP.Service + const result = yield* mcp.add("hanging-stdio", { + type: "local", + command: [process.execPath, stdioFixture, "--hang"], + environment: { MCP_LIFECYCLE_PID_FILE: pidFile }, + timeout: 100, + }) + + expect(statusName(result.status, "hanging-stdio")).toBe("failed") + const pid = yield* pollWithTimeout( + Effect.promise(async () => { + const file = Bun.file(pidFile) + return (await file.exists()) ? Number(await file.text()) : undefined + }), + "stdio fixture did not publish its pid", + ) + yield* pollWithTimeout( + Effect.sync(() => { + try { + process.kill(pid, 0) + return undefined + } catch { + return true } }), - ), - { config: { mcp: {} } }, + "stdio fixture process was not terminated", + ) + }), ) -// ======================================================================== -// Test: tools() with no MCP servers configured -// ======================================================================== +it.instance("remote timeout aborts both real HTTP transport attempts", () => + Effect.gen(function* () { + const server = yield* hangingLifecycleServer() + const mcp = yield* MCP.Service + const result = yield* mcp.add("hanging-remote", remote(server.url, 100)) -it.instance( - "tools() returns empty when no MCP servers are configured", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - const tools = yield* mcp.tools() - expect(Object.keys(tools).length).toBe(0) - }), - ), - { config: { mcp: {} } }, + expect(statusName(result.status, "hanging-remote")).toBe("failed") + yield* pollWithTimeout( + Effect.sync(() => (server.aborted() >= 2 ? server.aborted() : undefined)), + "remote transport requests were not aborted", + ) + expect(server.requests).toEqual(["POST", "GET"]) + }), ) -// ======================================================================== -// Test: connect failure during create() -// ======================================================================== - -it.instance( - "server that fails to connect is marked as failed", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "fail-connect" - getOrCreateClientState("fail-connect") - connectShouldFail = true - connectError = "Connection refused" - - yield* mcp.add("fail-connect", { - type: "local", - command: ["echo", "test"], - }) - - const status = yield* mcp.status() - expect(status["fail-connect"]?.status).toBe("failed") - if (status["fail-connect"]?.status === "failed") { - expect(status["fail-connect"].error).toContain("Connection refused") - } - - // No tools should be available - const tools = yield* mcp.tools() - expect(Object.keys(tools).length).toBe(0) - }), - ), - { - config: { - mcp: { - "fail-connect": { - type: "local", - command: ["echo", "test"], - }, - }, - }, - }, -) - -// ======================================================================== -// Bug #5: McpOAuthCallback.cancelPending uses wrong key -// ======================================================================== - -it.live("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses oauthState", () => +it.live("McpOAuthCallback.cancelPending rejects the pending callback", () => Effect.acquireUseRelease( Effect.sync(() => McpOAuthCallback.waitForCallback("abc123hexstate", "my-mcp-server")), (callback) => Effect.gen(function* () { McpOAuthCallback.cancelPending("my-mcp-server") - const exit = yield* Effect.tryPromise({ try: () => callback, catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }).pipe( - Effect.timeoutOrElse({ - duration: "1 second", - orElse: () => Effect.fail(new Error("timed out waiting for OAuth cancellation")), - }), - Effect.exit, - ) - + }).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) }), () => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore), ), ) - -// ======================================================================== -// Test: multiple tools from same server get correct name prefixes -// ======================================================================== - -it.instance( - "tools() prefixes tool names with sanitized server name", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "my.special-server" - const serverState = getOrCreateClientState("my.special-server") - serverState.tools = [ - { name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } }, - { name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } }, - ] - - yield* mcp.add("my.special-server", { - type: "local", - command: ["echo", "test"], - }) - - const tools = yield* mcp.tools() - const keys = Object.keys(tools) - - // Server name dots should be replaced with underscores - expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true) - // Tool name dots should be replaced with underscores - expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true) - expect(keys.length).toBe(2) - }), - ), - { - config: { - mcp: { - "my.special-server": { - type: "local", - command: ["echo", "test"], - }, - }, - }, - }, -) - -// ======================================================================== -// Test: transport leak — local stdio timeout (#19168) -// ======================================================================== - -it.instance( - "local stdio transport is closed when connect times out (no process leak)", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "hanging-server" - getOrCreateClientState("hanging-server") - connectShouldHang = true - - const addResult = yield* mcp.add("hanging-server", { - type: "local", - command: ["node", "fake.js"], - timeout: 100, - }) - - const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status - expect(serverStatus.status).toBe("failed") - expect(serverStatus.error).toContain("timed out") - // Transport must be closed to avoid orphaned child process - expect(transportCloseCount).toBeGreaterThanOrEqual(1) - }), - ), - { config: { mcp: {} } }, -) - -// ======================================================================== -// Test: transport leak — remote timeout (#19168) -// ======================================================================== - -it.instance( - "remote transport is closed when connect times out", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "hanging-remote" - getOrCreateClientState("hanging-remote") - connectShouldHang = true - - const addResult = yield* mcp.add("hanging-remote", { - type: "remote", - url: "http://localhost:9999/mcp", - timeout: 100, - oauth: false, - }) - - const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status - expect(serverStatus.status).toBe("failed") - // Transport must be closed to avoid leaked HTTP connections - expect(transportCloseCount).toBeGreaterThanOrEqual(1) - }), - ), - { config: { mcp: {} } }, -) - -// ======================================================================== -// Test: transport leak — failed remote transports not closed (#19168) -// ======================================================================== - -it.instance( - "failed remote transport is closed before trying next transport", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "fail-remote" - getOrCreateClientState("fail-remote") - connectShouldFail = true - connectError = "Connection refused" - - const addResult = yield* mcp.add("fail-remote", { - type: "remote", - url: "http://localhost:9999/mcp", - timeout: 5000, - oauth: false, - }) - - const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status - expect(serverStatus.status).toBe("failed") - // Both StreamableHTTP and SSE transports should be closed - expect(transportCloseCount).toBeGreaterThanOrEqual(2) - }), - ), - { config: { mcp: {} } }, -) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index febbe0d0bdc7..5f8889068c33 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -1,199 +1,155 @@ -import { expect, mock, beforeEach } from "bun:test" +import { expect } from "bun:test" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { ListResourcesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect } from "effect" +import { Config } from "../../src/config/config" +import { EventV2Bridge } from "../../src/event-v2-bridge" +import { McpAuth } from "../../src/mcp/auth" +import { MCP } from "../../src/mcp/index" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" +import { McpOAuthPendingProvider, McpOAuthProvider } from "../../src/mcp/oauth-provider" import { testEffect } from "../lib/effect" -// Mock UnauthorizedError to match the SDK's class -class MockUnauthorizedError extends Error { - constructor(message?: string) { - super(message ?? "Unauthorized") - this.name = "UnauthorizedError" - } +const mcpTest = testEffect( + LayerNode.compile( + LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]), + ), +) + +interface OAuthMcpOptions { + capabilities?: "tools" | "resources" } -// Track what options were passed to each transport constructor -const transportCalls: Array<{ - type: "streamable" | "sse" - url: string - options: { authProvider?: unknown } -}> = [] - -// Controls whether the mock transport simulates a 401 that triggers the SDK -// auth flow (which calls provider.state()) or a simple UnauthorizedError. -let simulateAuthFlow = true -let connectSucceedsImmediately = false -let serverCapabilities: { tools?: object; resources?: object } = { tools: {} } -let listToolsCalls = 0 -let finishAuthFails = false -let finishAuthStoresCredentials = false - -// Mock the transport constructors to simulate OAuth auto-auth on 401 -void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ - StreamableHTTPClientTransport: class MockStreamableHTTP { - authProvider: - | { - state?: () => Promise - redirectToAuthorization?: (url: URL) => Promise - saveCodeVerifier?: (v: string) => Promise - tokens?: () => Promise<{ access_token: string } | undefined> - clientInformation?: () => Promise<{ client_id: string } | undefined> - saveClientInformation?: (info: { client_id: string; client_secret?: string }) => Promise - saveTokens?: (tokens: { access_token: string; token_type: string }) => Promise - } - | undefined - constructor(url: URL, options?: { authProvider?: unknown }) { - this.authProvider = options?.authProvider as typeof this.authProvider - transportCalls.push({ - type: "streamable", - url: url.toString(), - options: options ?? {}, +function serveOAuthMcp(options: OAuthMcpOptions = {}) { + return Effect.acquireRelease( + Effect.promise(async () => { + const capabilities = options.capabilities ?? "tools" + const protocol = new Server( + { name: "oauth-auto-connect", version: "1.0.0" }, + { capabilities: capabilities === "tools" ? { tools: {} } : { resources: {} } }, + ) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, }) - } - async start() { - if (connectSucceedsImmediately) return - - // Simulate what the real SDK transport does on 401: - // It calls auth() which eventually calls provider.state(), then - // provider.redirectToAuthorization(), then throws UnauthorizedError. - if (simulateAuthFlow && this.authProvider) { - if (await this.authProvider.tokens?.()) throw new MockUnauthorizedError() - if (await this.authProvider.clientInformation?.()) throw new MockUnauthorizedError() - // The SDK calls provider.state() to get the OAuth state parameter - if (this.authProvider.state) { - await this.authProvider.state() - } - // The SDK calls saveCodeVerifier before redirecting - if (this.authProvider.saveCodeVerifier) { - await this.authProvider.saveCodeVerifier("test-verifier") - } - // The SDK calls redirectToAuthorization to redirect the user - if (this.authProvider.redirectToAuthorization) { - await this.authProvider.redirectToAuthorization(new URL("https://auth.example.com/authorize?state=test")) - } - throw new MockUnauthorizedError() + let listToolsCalls = 0 + let requiresAuth = true + + if (capabilities === "tools") { + protocol.setRequestHandler(ListToolsRequestSchema, () => { + listToolsCalls++ + return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] }) + }) } - throw new MockUnauthorizedError() - } - async finishAuth(_code: string) { - if (finishAuthFails) throw new Error("Token exchange failed") - if (finishAuthStoresCredentials) { - await this.authProvider?.saveClientInformation?.({ client_id: "replacement-client" }) - await this.authProvider?.saveTokens?.({ access_token: "replacement-token", token_type: "Bearer" }) + if (capabilities === "resources") { + protocol.setRequestHandler(ListResourcesRequestSchema, () => + Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }), + ) } - } - async close() {} - }, -})) - -void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ - SSEClientTransport: class MockSSE { - constructor(url: URL, options?: { authProvider?: unknown }) { - transportCalls.push({ - type: "sse", - url: url.toString(), - options: options ?? {}, - }) - } - async start() { - throw new Error("Mock SSE transport cannot connect") - } - }, -})) - -// Mock the MCP SDK Client -void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ - Client: class MockClient { - setRequestHandler() {} - - async connect(transport: { start: () => Promise }) { - await transport.start() - } - - setNotificationHandler() {} - - getServerCapabilities() { - return serverCapabilities - } - - getInstructions() {} - - async listTools() { - listToolsCalls++ - return { tools: [{ name: "test_tool", inputSchema: { type: "object", properties: {} } }] } - } - - async listResources() { - return { resources: [{ name: "docs", uri: "docs://readme" }] } - } - - async close() {} - }, -})) - -// Mock UnauthorizedError in the auth module so instanceof checks work -void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ - UnauthorizedError: MockUnauthorizedError, -})) - -beforeEach(() => { - transportCalls.length = 0 - simulateAuthFlow = true - connectSucceedsImmediately = false - serverCapabilities = { tools: {} } - listToolsCalls = 0 - finishAuthFails = false - finishAuthStoresCredentials = false -}) -// Import modules after mocking -const { MCP } = await import("../../src/mcp/index") -const { EventV2Bridge } = await import("../../src/event-v2-bridge") -const { Config } = await import("../../src/config/config") -const { McpAuth } = await import("../../src/mcp/auth") -const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider") -const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") -const { FSUtil } = await import("@opencode-ai/core/fs-util") -const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner") + await protocol.connect(transport) + const http = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url) + const origin = url.origin + const mcpUrl = `${origin}/mcp` + + if (url.pathname === "/.well-known/oauth-protected-resource/mcp") { + return Response.json({ + resource: mcpUrl, + authorization_servers: [origin], + scopes_supported: ["mcp"], + }) + } + if (url.pathname === "/.well-known/oauth-protected-resource") { + return Response.json({ + resource: mcpUrl, + authorization_servers: [origin], + scopes_supported: ["mcp"], + }) + } + if (url.pathname === "/.well-known/oauth-authorization-server") { + return Response.json({ + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + registration_endpoint: `${origin}/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: ["none"], + code_challenge_methods_supported: ["S256"], + scopes_supported: ["mcp"], + }) + } + if (url.pathname === "/register") { + const metadata = (await request.json()) as Record + return Response.json({ ...metadata, client_id: "replacement-client" }, { status: 201 }) + } + if (url.pathname === "/token") { + const body = new URLSearchParams(await request.text()) + if (body.get("code") !== "valid-code") { + return Response.json( + { error: "invalid_grant", error_description: "Token exchange failed" }, + { status: 400 }, + ) + } + return Response.json({ access_token: "replacement-token", token_type: "Bearer" }) + } + if (url.pathname !== "/mcp") return new Response("Not found", { status: 404 }) + + if (request.method === "GET") return new Response(null, { status: 405 }) + if (requiresAuth && request.headers.get("authorization") !== "Bearer replacement-token") { + return new Response("Unauthorized", { + status: 401, + headers: { + "WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource", scope="mcp"`, + }, + }) + } + return transport.handleRequest(request) + }, + }) -const mcpTest = testEffect( - LayerNode.compile( - LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]), - ), -) + return { + url: new URL("/mcp", http.url).toString(), + allowAnonymous: () => { + requiresAuth = false + }, + listToolsCalls: () => listToolsCalls, + close: async () => { + await http.stop(true) + await protocol.close() + }, + } + }), + (server) => Effect.promise(server.close), + ) +} -const config = (name: string) => ({ - mcp: { - [name]: { - type: "remote" as const, - url: "https://example.com/mcp", - }, - }, +const remote = (url: string, enabled = true) => ({ + type: "remote" as const, + url, + enabled, }) -mcpTest.instance( - "first connect to OAuth server shows needs_auth instead of failed", - () => - MCP.Service.use((mcp) => - Effect.gen(function* () { - const result = yield* mcp.add("test-oauth", { - type: "remote", - url: "https://example.com/mcp", - }) +const stopOAuthCallback = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) - const serverStatus = result.status as Record - - // The server should be detected as needing auth, NOT as failed. - // Before the fix, provider.state() would throw a plain Error - // ("No OAuth state saved for MCP server: test-oauth") which was - // not caught as UnauthorizedError, causing status to be "failed". - expect(serverStatus["test-oauth"]).toBeDefined() - expect(serverStatus["test-oauth"].status).toBe("needs_auth") - }), - ), - { config: config("test-oauth") }, +mcpTest.instance("first connect to OAuth server shows needs_auth instead of failed", () => + Effect.gen(function* () { + const server = yield* serveOAuthMcp() + const mcp = yield* MCP.Service + const result = yield* mcp.add("test-oauth", remote(server.url)) + + expect((result.status as Record)["test-oauth"]).toEqual({ status: "needs_auth" }) + }), ) -mcpTest.instance("state() generates a new state when none is saved", () => +mcpTest.instance("state() generates and persists a new state when none is saved", () => Effect.gen(function* () { const auth = yield* McpAuth.Service const provider = new McpOAuthProvider( @@ -204,17 +160,11 @@ mcpTest.instance("state() generates a new state when none is saved", () => auth, ) - const entryBefore = yield* McpAuth.use.get("test-state-gen") - expect(entryBefore?.oauthState).toBeUndefined() + expect((yield* auth.get("test-state-gen"))?.oauthState).toBeUndefined() - // state() should generate and return a new state, not throw const state = yield* Effect.promise(() => provider.state()) - expect(typeof state).toBe("string") - expect(state.length).toBe(64) // 32 bytes as hex - - // The generated state should be persisted - const entryAfter = yield* McpAuth.use.get("test-state-gen") - expect(entryAfter?.oauthState).toBe(state) + expect(state).toHaveLength(64) + expect((yield* auth.get("test-state-gen"))?.oauthState).toBe(state) }), ) @@ -229,139 +179,122 @@ mcpTest.instance("state() returns existing state when one is saved", () => auth, ) - // Pre-save a state - const existingState = "pre-saved-state-value" - yield* McpAuth.use.updateOAuthState("test-state-existing", existingState) + yield* auth.updateOAuthState("test-state-existing", "pre-saved-state-value") + expect(yield* Effect.promise(() => provider.state())).toBe("pre-saved-state-value") + }), +) - // state() should return the existing state - const state = yield* Effect.promise(() => provider.state()) - expect(state).toBe(existingState) +mcpTest.instance("pending provider does not expose or overwrite existing credentials before commit", () => + Effect.gen(function* () { + const auth = yield* McpAuth.Service + const name = "test-pending-credentials" + const url = "https://example.com/mcp" + const provider = new McpOAuthPendingProvider(name, url, {}, { onRedirect: async () => {} }, auth) + + yield* auth.updateClientInfo(name, { clientId: "old-client" }, url) + yield* auth.updateTokens(name, { accessToken: "old-token" }, url) + + expect(yield* Effect.promise(() => provider.clientInformation())).toBeUndefined() + expect(yield* Effect.promise(() => provider.tokens())).toBeUndefined() + expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token") + expect((yield* auth.get(name))?.clientInfo?.clientId).toBe("old-client") }), ) -mcpTest.instance( - "failed reauthentication preserves existing credentials", - () => - Effect.gen(function* () { - yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) - const mcp = yield* MCP.Service - const auth = yield* McpAuth.Service - const name = "test-reauth-failure" - const url = "https://example.com/mcp" - const clientInfo = { clientId: "dynamic-client", clientSecret: "dynamic-secret" } - - yield* auth.updateClientInfo(name, clientInfo, url) - yield* auth.updateTokens(name, { accessToken: "working-token" }, url) - expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize") - finishAuthFails = true - - expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({ - status: "failed", - error: "OAuth completion failed: Token exchange failed", - }) - const entry = yield* auth.get(name) - expect(entry?.tokens?.accessToken).toBe("working-token") - expect(entry?.clientInfo).toEqual(clientInfo) - }), - { config: config("test-reauth-failure") }, +mcpTest.instance("failed reauthentication preserves existing credentials", () => + Effect.gen(function* () { + yield* stopOAuthCallback + const server = yield* serveOAuthMcp() + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + const name = "test-reauth-failure" + + yield* auth.updateClientInfo(name, { clientId: "dynamic-client", clientSecret: "dynamic-secret" }, server.url) + yield* auth.updateTokens(name, { accessToken: "working-token" }, server.url) + yield* mcp.add(name, remote(server.url)) + expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("/authorize") + + expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({ + status: "failed", + error: "OAuth completion failed: Token exchange failed", + }) + expect((yield* auth.get(name))?.tokens?.accessToken).toBe("working-token") + expect((yield* auth.get(name))?.clientInfo).toMatchObject({ + clientId: "dynamic-client", + clientSecret: "dynamic-secret", + }) + }), ) -mcpTest.instance( - "successful reauthentication commits replacement credentials", - () => - Effect.gen(function* () { - yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) - const mcp = yield* MCP.Service - const auth = yield* McpAuth.Service - const name = "test-reauth-success" - const url = "https://example.com/mcp" - - yield* auth.updateClientInfo(name, { clientId: "old-client" }, url) - yield* auth.updateTokens(name, { accessToken: "old-token" }, url) - expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize") - expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token") - finishAuthStoresCredentials = true - connectSucceedsImmediately = true - - expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected") - const entry = yield* auth.get(name) - expect(entry?.tokens?.accessToken).toBe("replacement-token") - expect(entry?.clientInfo?.clientId).toBe("replacement-client") - expect(entry?.serverUrl).toBe(url) - }), - { config: config("test-reauth-success") }, +mcpTest.instance("successful reauthentication commits replacement credentials", () => + Effect.gen(function* () { + yield* stopOAuthCallback + const server = yield* serveOAuthMcp() + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + const name = "test-reauth-success" + + yield* auth.updateClientInfo(name, { clientId: "old-client" }, server.url) + yield* auth.updateTokens(name, { accessToken: "old-token" }, server.url) + yield* mcp.add(name, remote(server.url)) + expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("/authorize") + expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token") + + expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected") + const entry = yield* auth.get(name) + expect(entry?.tokens?.accessToken).toBe("replacement-token") + expect(entry?.clientInfo?.clientId).toBe("replacement-client") + expect(entry?.serverUrl).toBe(server.url) + }), ) -mcpTest.instance( - "auth status only reports credentials stored for the configured server URL", - () => - Effect.gen(function* () { - const mcp = yield* MCP.Service - expect(transportCalls).toHaveLength(0) - yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "old-token" }, "https://old.example.com/mcp") +mcpTest.instance("auth status only reports credentials stored for the configured server URL", () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + yield* mcp.add("test-status-url", remote("https://example.com/mcp", false)) + yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "old-token" }, "https://old.example.com/mcp") - expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated") + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated") - yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "current-token" }, "https://example.com/mcp") - expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated") + yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "current-token" }, "https://example.com/mcp") + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated") - yield* McpAuth.use.updateTokens( - "test-status-url", - { accessToken: "expired-token", expiresAt: 1 }, - "https://example.com/mcp", - ) - expect(yield* mcp.getAuthStatus("test-status-url")).toBe("expired") - expect(transportCalls).toHaveLength(0) - }), - { config: config("test-status-url") }, + yield* McpAuth.use.updateTokens( + "test-status-url", + { accessToken: "expired-token", expiresAt: 1 }, + "https://example.com/mcp", + ) + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("expired") + }), ) -mcpTest.instance( - "authenticate() stores a connected client when auth completes without redirect", - () => - MCP.Service.use((mcp) => - Effect.gen(function* () { - const added = yield* mcp.add("test-oauth-connect", { - type: "remote", - url: "https://example.com/mcp", - }) - const before = added.status as Record - expect(before["test-oauth-connect"]?.status).toBe("needs_auth") - - simulateAuthFlow = false - connectSucceedsImmediately = true - - const result = yield* mcp.authenticate("test-oauth-connect") - expect(result.status).toBe("connected") - - const after = yield* mcp.status() - expect(after["test-oauth-connect"]?.status).toBe("connected") - }), - ), - { config: config("test-oauth-connect") }, +mcpTest.instance("authenticate() stores a connected client when auth completes without redirect", () => + Effect.gen(function* () { + yield* stopOAuthCallback + const server = yield* serveOAuthMcp() + const mcp = yield* MCP.Service + const name = "test-oauth-connect" + const added = yield* mcp.add(name, remote(server.url)) + expect((added.status as Record)[name]?.status).toBe("needs_auth") + + server.allowAnonymous() + expect((yield* mcp.authenticate(name)).status).toBe("connected") + expect((yield* mcp.status())[name]?.status).toBe("connected") + }), ) -mcpTest.instance( - "authenticate() connects a resource-only server without listing tools", - () => - MCP.Service.use((mcp) => - Effect.gen(function* () { - const added = yield* mcp.add("test-oauth-resources", { - type: "remote", - url: "https://example.com/mcp", - }) - const before = added.status as Record - expect(before["test-oauth-resources"]?.status).toBe("needs_auth") - - simulateAuthFlow = false - connectSucceedsImmediately = true - serverCapabilities = { resources: {} } - - const result = yield* mcp.authenticate("test-oauth-resources") - expect(result.status).toBe("connected") - expect(listToolsCalls).toBe(0) - expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs://readme"]) - }), - ), - { config: config("test-oauth-resources") }, +mcpTest.instance("authenticate() connects a resource-only server without listing tools", () => + Effect.gen(function* () { + yield* stopOAuthCallback + const server = yield* serveOAuthMcp({ capabilities: "resources" }) + const mcp = yield* MCP.Service + const name = "test-oauth-resources" + const added = yield* mcp.add(name, remote(server.url)) + expect((added.status as Record)[name]?.status).toBe("needs_auth") + + server.allowAnonymous() + expect((yield* mcp.authenticate(name)).status).toBe("connected") + expect(server.listToolsCalls()).toBe(0) + expect(Object.keys(yield* mcp.resources())).toEqual([`${name}:docs://readme`]) + }), ) diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 07b157ecc98b..9573805a9a14 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -1,152 +1,138 @@ -import { expect, mock, beforeEach } from "bun:test" -import { EventEmitter } from "events" +import { 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 { Deferred, Effect, Layer, Option } from "effect" +import { Config } from "../../src/config/config" +import { EventV2Bridge } from "../../src/event-v2-bridge" +import { McpAuth } from "../../src/mcp/auth" +import { McpBrowser } from "../../src/mcp/browser" +import { MCP } from "../../src/mcp/index" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" import { awaitWithTimeout, testEffect } from "../lib/effect" -import type { MCP as MCPNS } from "../../src/mcp/index" - -// Track open() calls and control failure behavior -let openShouldFail = false -let openCalledWith: string | undefined -let openDeferred: Deferred.Deferred | undefined - -void mock.module("open", () => ({ - default: async (url: string) => { - openCalledWith = url - if (openDeferred) Effect.runSync(Deferred.succeed(openDeferred, url).pipe(Effect.ignore)) - - // Return a mock subprocess that emits an error if openShouldFail is true - const subprocess = new EventEmitter() - if (openShouldFail) { - // Emit error asynchronously like a real subprocess would - setTimeout(() => { - subprocess.emit("error", new Error("spawn xdg-open ENOENT")) - }, 10) - } - return subprocess - }, -})) - -// Mock UnauthorizedError -class MockUnauthorizedError extends Error { - constructor() { - super("Unauthorized") - this.name = "UnauthorizedError" - } -} - -// 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 -void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ - StreamableHTTPClientTransport: class MockStreamableHTTP { - url: string - authProvider: { redirectToAuthorization?: (url: URL) => Promise } | undefined - constructor( - url: URL, - options?: { authProvider?: { redirectToAuthorization?: (url: URL) => Promise }; requestInit?: RequestInit }, - ) { - this.url = url.toString() - this.authProvider = options?.authProvider - transportCalls.push({ - type: "streamable", - url: url.toString(), - options: options ?? {}, - }) - } - async start() { - // Simulate OAuth redirect by calling the authProvider's redirectToAuthorization - 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 successful auth completion - } - }, -})) - -void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ - SSEClientTransport: class MockSSE { - constructor(url: URL) { - transportCalls.push({ - type: "sse", - url: url.toString(), - options: {}, - }) - } - async start() { - throw new Error("Mock SSE transport cannot connect") - } - }, -})) -// Mock the MCP SDK Client to trigger OAuth flow -void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ - Client: class MockClient { - setRequestHandler() {} +const browsers = new Map; fail: boolean }>() + +const browserLayer = Layer.succeed( + McpBrowser.Service, + McpBrowser.Service.of({ + open: (url) => + Effect.gen(function* () { + const browser = browsers.get(new URL(url).origin) + if (!browser) return yield* Effect.fail(new Error(`Unexpected browser URL: ${url}`)) + Deferred.doneUnsafe(browser.opened, Effect.succeed(url)) + if (browser.fail) return yield* Effect.fail(new Error("spawn xdg-open ENOENT")) + yield* Effect.tryPromise({ + try: () => fetch(url).then((response) => response.body?.cancel()), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + }), + }), +) - async connect(transport: { start: () => Promise }) { - await transport.start() - } +const mcpTest = testEffect( + LayerNode.compile(LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node]), [ + [McpBrowser.node, browserLayer], + ]), +) - getServerCapabilities() { - return { tools: {} } +const serveOAuthMcp = Effect.acquireRelease( + Effect.promise(async () => { + const requests: Array<{ pathname: string; headers: Headers }> = [] + const protocol = new Server({ name: "oauth-browser", 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({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url) + requests.push({ pathname: url.pathname, headers: new Headers(request.headers) }) + + if (url.pathname === "/mcp") { + if (request.headers.get("authorization") === "Bearer test-access-token") { + return transport.handleRequest(request) + } + return new Response("Unauthorized", { + status: 401, + headers: { + "WWW-Authenticate": `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource/mcp", scope="mcp"`, + }, + }) + } + + if (url.pathname === "/.well-known/oauth-protected-resource/mcp") { + return Response.json({ + resource: `${url.origin}/mcp`, + authorization_servers: [url.origin], + scopes_supported: ["mcp"], + }) + } + + if (url.pathname === "/.well-known/oauth-authorization-server") { + return Response.json({ + issuer: url.origin, + authorization_endpoint: `${url.origin}/authorize`, + token_endpoint: `${url.origin}/token`, + registration_endpoint: `${url.origin}/register`, + scopes_supported: ["mcp"], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + token_endpoint_auth_methods_supported: ["none"], + code_challenge_methods_supported: ["S256"], + }) + } + + if (url.pathname === "/register") { + const metadata = await request.json() + if (!metadata || typeof metadata !== "object") return new Response("Invalid metadata", { status: 400 }) + return Response.json({ ...metadata, client_id: "test-client" }, { status: 201 }) + } + + if (url.pathname === "/authorize") { + const redirect = new URL(url.searchParams.get("redirect_uri") ?? "") + redirect.searchParams.set("code", "test-code") + const state = url.searchParams.get("state") + if (state) redirect.searchParams.set("state", state) + return Response.redirect(redirect, 302) + } + + if (url.pathname === "/token") { + return Response.json({ access_token: "test-access-token", token_type: "Bearer", scope: "mcp" }) + } + + return new Response("Not found", { status: 404 }) + }, + }) + + return { + requests, + url: new URL("/mcp", http.url).toString(), + close: async () => { + await http.stop(true) + await protocol.close() + }, } - }, -})) - -// Mock UnauthorizedError in the auth module -void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ - UnauthorizedError: MockUnauthorizedError, -})) - -beforeEach(() => { - openShouldFail = false - openCalledWith = undefined - openDeferred = undefined - transportCalls.length = 0 -}) - -// Import modules after mocking -const { MCP } = await import("../../src/mcp/index") -const { EventV2Bridge } = await import("../../src/event-v2-bridge") -const { Config } = await import("../../src/config/config") -const { McpAuth } = await import("../../src/mcp/auth") -const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") -const { FSUtil } = await import("@opencode-ai/core/fs-util") -const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner") -const mcpTest = testEffect( - LayerNode.compile( - LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]), - ), + }), + (server) => Effect.promise(server.close), ) -const service = MCP.Service as unknown as Effect.Effect - -const config = (name: string, headers?: Record) => ({ - mcp: { - [name]: { - type: "remote" as const, - url: "https://example.com/mcp", - headers, - }, - }, -}) const withCallbackStop = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) -const trackBrowserOpen = Effect.gen(function* () { - const opened = yield* Deferred.make() - openDeferred = opened - yield* Effect.addFinalizer(() => Effect.sync(() => (openDeferred = undefined))) - return opened -}) +const trackBrowserOpen = (url: string, fail = false) => + Effect.gen(function* () { + const origin = new URL(url).origin + const opened = yield* Deferred.make() + browsers.set(origin, { opened, fail }) + yield* Effect.addFinalizer(() => Effect.sync(() => browsers.delete(origin))) + return opened + }) const trackBrowserOpenFailed = Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -160,84 +146,80 @@ const trackBrowserOpenFailed = Effect.gen(function* () { return event }) -const authenticateScoped = (name: string, onAuthorization?: (authorizationUrl: string) => void) => +const addServer = Effect.fnUntraced(function* (name: string, url: string, headers?: Record) { + const mcp = yield* MCP.Service + const result = yield* mcp.add(name, { type: "remote", url, headers }) + expect(result.status).toMatchObject({ [name]: { status: "needs_auth" } }) + return mcp +}) + +mcpTest.instance("BrowserOpenFailed event is published when browser launch fails", () => Effect.gen(function* () { - const mcp = yield* service - yield* mcp.authenticate(name, onAuthorization).pipe( - Effect.ignore, - Effect.catchCause(() => Effect.void), - Effect.forkScoped, + yield* withCallbackStop + const server = yield* serveOAuthMcp + yield* trackBrowserOpen(server.url, true) + + const event = yield* trackBrowserOpenFailed + const mcp = yield* addServer("test-oauth-server", server.url) + yield* mcp.authenticate("test-oauth-server").pipe(Effect.ignore, Effect.forkScoped) + + const failure = yield* awaitWithTimeout( + Deferred.await(event), + "Timed out waiting for BrowserOpenFailed event", + "5 seconds", ) - }) -mcpTest.instance( - "BrowserOpenFailed event is published when open() throws", - () => - Effect.gen(function* () { - yield* withCallbackStop - openShouldFail = true - - const event = yield* trackBrowserOpenFailed - yield* authenticateScoped("test-oauth-server") - - const failure = yield* awaitWithTimeout( - Deferred.await(event), - "Timed out waiting for BrowserOpenFailed event", - "5 seconds", - ) - - expect(failure.mcpName).toBe("test-oauth-server") - expect(failure.url).toContain("https://") - }), - { config: config("test-oauth-server") }, + expect(failure.mcpName).toBe("test-oauth-server") + expect(failure.url).toStartWith(new URL("/authorize", server.url).toString()) + }), ) -mcpTest.instance( - "BrowserOpenFailed event is NOT published when open() succeeds", - () => - Effect.gen(function* () { - yield* withCallbackStop - openShouldFail = false - - const opened = yield* trackBrowserOpen - const event = yield* trackBrowserOpenFailed - yield* authenticateScoped("test-oauth-server-2") - - yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()", "5 seconds") - const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis")) +mcpTest.instance("BrowserOpenFailed event is not published when browser launch succeeds", () => + Effect.gen(function* () { + yield* withCallbackStop + const server = yield* serveOAuthMcp + + const opened = yield* trackBrowserOpen(server.url) + const event = yield* trackBrowserOpenFailed + const mcp = yield* addServer("test-oauth-server-2", server.url) + const status = yield* awaitWithTimeout( + mcp.authenticate("test-oauth-server-2"), + "Timed out completing OAuth authentication", + "5 seconds", + ) + const url = yield* Deferred.await(opened) + const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis")) - expect(failure).toEqual(Option.none()) - expect(openCalledWith).toBeDefined() - }), - { config: config("test-oauth-server-2") }, + expect(status).toEqual({ status: "connected" }) + expect(failure).toEqual(Option.none()) + expect(new URL(url).origin).toBe(new URL(server.url).origin) + }), ) -mcpTest.instance( - "open() is called with the authorization URL", - () => - Effect.gen(function* () { - yield* withCallbackStop - openShouldFail = false - openCalledWith = undefined - - const opened = yield* trackBrowserOpen - const event = yield* trackBrowserOpenFailed - const authorization = yield* Deferred.make() - yield* authenticateScoped("test-oauth-server-3", (url) => Deferred.doneUnsafe(authorization, Effect.succeed(url))) - - const url = yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()", "5 seconds") - const authorizationUrl = yield* awaitWithTimeout( - Deferred.await(authorization), - "Timed out waiting for authorization URL", - "5 seconds", - ) - const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis")) - - expect(failure).toEqual(Option.none()) - expect(authorizationUrl).toBe(url) - expect(typeof url).toBe("string") - expect(url).toContain("https://") - expect(transportCalls.at(-1)?.options.requestInit?.headers).toEqual({ "X-Custom-Header": "custom-value" }) - }), - { config: config("test-oauth-server-3", { "X-Custom-Header": "custom-value" }) }, +mcpTest.instance("browser launch receives the discovered authorization URL", () => + Effect.gen(function* () { + yield* withCallbackStop + const server = yield* serveOAuthMcp + + const opened = yield* trackBrowserOpen(server.url) + const authorization = yield* Deferred.make() + const mcp = yield* addServer("test-oauth-server-3", server.url, { "X-Custom-Header": "custom-value" }) + const status = yield* awaitWithTimeout( + mcp.authenticate("test-oauth-server-3", (url) => Deferred.doneUnsafe(authorization, Effect.succeed(url))), + "Timed out completing OAuth authentication", + "5 seconds", + ) + const url = yield* Deferred.await(opened) + const authorizationUrl = yield* Deferred.await(authorization) + + expect(status).toEqual({ status: "connected" }) + expect(authorizationUrl).toBe(url) + expect(new URL(url).pathname).toBe("/authorize") + expect(new URL(url).searchParams.get("client_id")).toBe("test-client") + expect( + server.requests.some( + (request) => request.pathname === "/mcp" && request.headers.get("x-custom-header") === "custom-value", + ), + ).toBe(true) + }), )