import path from "node:path" import { describe, expect, test } from "bun:test" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { ConfigMCP } from "@opencode-ai/core/config/mcp" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { MCP } from "@opencode-ai/core/mcp/index" import { MCPClient } from "@opencode-ai/core/mcp/client" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" import { McpTool } from "@opencode-ai/core/tool/mcp" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { Deferred, Effect, Fiber, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" let assertion: Deferred.Deferred | undefined let decision: Effect.Effect = Effect.void let calls = 0 const mcp = Layer.mock(MCP.Service, { tools: () => Effect.succeed([ new MCP.Tool({ server: MCP.ServerName.make("demo"), name: "search", description: "Search", inputSchema: { type: "object", properties: {} }, outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], }, }), ]), callTool: (input) => Effect.sync(() => { calls += 1 return new MCP.ToolResult({ server: MCP.ServerName.make(input.server), tool: input.name, isError: false, structured: { ok: true }, content: [], }) }), }) const permissions = Layer.mock(PermissionV2.Service, { assert: (input) => Effect.gen(function* () { if (!assertion) return yield* Effect.die("Permission test is not initialized") yield* Deferred.succeed(assertion, input) yield* decision }), }) const events = Layer.mock(EventV2.Service, { subscribe: () => Stream.never }) const it = testEffect( AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, McpTool.node]), [ [MCP.node, mcp], [PermissionV2.node, permissions], [EventV2.node, events], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], ]), ) describe("MCP errors", () => { test("expose useful messages", () => { expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo") expect( new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message, ).toBe("failed") expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo") expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline") }) }) test("MCP tool names match V1 sanitization", () => { expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id") }) test("preserves output schema validation across paginated tool discovery", async () => { const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) server.setRequestHandler(ListToolsRequestSchema, ({ params }) => Promise.resolve( params?.cursor === "page-2" ? { tools: [ { name: "second", inputSchema: { type: "object" }, outputSchema: { type: "object", properties: { value: { type: "number" } }, required: ["value"], }, }, ], } : { tools: [ { name: "first", inputSchema: { type: "object" }, outputSchema: { type: "object", properties: { value: { type: "string" } }, required: ["value"], }, }, ], nextCursor: "page-2", }, ), ) server.setRequestHandler(CallToolRequestSchema, ({ params }) => Promise.resolve({ content: [], structuredContent: { value: params.name === "first" ? 42 : 1 }, }), ) const client = new Client({ name: "pagination-test", version: "1.0.0" }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) try { const first = await client.listTools() const second = await client.listTools({ cursor: first.nextCursor }) expect([...first.tools, ...second.tools].map((tool) => tool.name)).toEqual(["first", "second"]) await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow( "Structured content does not match the tool's output schema", ) } finally { await Promise.all([client.close(), server.close()]) } }) test("retains output schemas across paginated MCP discovery", async () => { const tools = await Effect.runPromise( Effect.scoped( Effect.gen(function* () { const connection = yield* MCPClient.connect( "pagination", new ConfigMCP.Local({ type: "local", command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")], }), import.meta.dir, ) return yield* connection.tools() }), ), ) expect(tools.map((tool) => ({ name: tool.name, outputSchema: tool.outputSchema }))).toEqual([ { name: "first", outputSchema: { type: "object", properties: { value: { type: "string" } }, required: ["value"], }, }, { name: "second", outputSchema: { type: "object", properties: { value: { type: "number" } }, required: ["value"], }, }, ]) }) test("applies the configured MCP catalog timeout", async () => { const result = Effect.runPromise( Effect.scoped( Effect.gen(function* () { const connection = yield* MCPClient.connect( "catalog-timeout", new ConfigMCP.Local({ type: "local", command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], environment: { MCP_TIMEOUT_TARGET: "catalog" }, timeout: new ConfigMCP.Timeout({ catalog: 10 }), }), import.meta.dir, ) return yield* connection.tools() }), ), ) await expect(result).rejects.toThrow("Request timed out") }) test("applies the configured MCP execution timeout", async () => { const result = Effect.runPromise( Effect.scoped( Effect.gen(function* () { const connection = yield* MCPClient.connect( "execution-timeout", new ConfigMCP.Local({ type: "local", command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], timeout: new ConfigMCP.Timeout({ execution: 10 }), }), import.meta.dir, ) return yield* connection.callTool({ name: "slow" }) }), ), ) await expect(result).rejects.toThrow("Request timed out") }) test("applies the configured MCP execution timeout to prompts", async () => { const result = Effect.runPromise( Effect.scoped( Effect.gen(function* () { const connection = yield* MCPClient.connect( "prompt-timeout", new ConfigMCP.Local({ type: "local", command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], timeout: new ConfigMCP.Timeout({ execution: 10 }), }), import.meta.dir, ) return yield* connection.prompt({ name: "slow" }) }), ), ) await expect(result).rejects.toThrow("Request timed out") }) it.effect("advertises MCP output schemas to Code Mode", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service yield* waitForTool(registry, "execute") const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute") expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{\n ok: boolean,\n}>") }), ) it.effect("waits for permission before calling an MCP tool", () => Effect.gen(function* () { calls = 0 assertion = yield* Deferred.make() const permission = yield* Deferred.make() decision = Deferred.await(permission) const registry = yield* ToolRegistry.Service yield* waitForTool(registry, "execute") const fiber = yield* settleTool(registry, { sessionID: SessionV2.ID.make("ses_mcp_permission"), ...toolIdentity, call: { type: "tool-call", id: "call_mcp_permission", name: "execute", input: { code: "return await tools.demo.search({})" }, }, }).pipe(Effect.forkScoped) expect(yield* Deferred.await(assertion)).toEqual({ action: "demo_search", resources: ["*"], save: ["*"], metadata: {}, sessionID: SessionV2.ID.make("ses_mcp_permission"), agent: toolIdentity.agent, source: { type: "tool", messageID: toolIdentity.assistantMessageID, callID: "call_mcp_permission", }, }) expect(calls).toBe(0) yield* Deferred.succeed(permission, undefined) yield* Fiber.join(fiber) expect(calls).toBe(1) }), ) it.effect("does not call MCP when permission is blocked", () => Effect.gen(function* () { calls = 0 assertion = yield* Deferred.make() decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] })) const registry = yield* ToolRegistry.Service yield* waitForTool(registry, "execute") const settlement = yield* settleTool(registry, { sessionID: SessionV2.ID.make("ses_mcp_blocked"), ...toolIdentity, call: { type: "tool-call", id: "call_mcp_blocked", name: "execute", input: { code: "return await tools.demo.search({})" }, }, }) expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" }) expect(settlement.output?.structured).toEqual({ toolCalls: [{ tool: "demo.search", status: "error" }], error: true, }) expect(calls).toBe(0) }), )