diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 1de661857..08d58118c 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -1,4 +1,5 @@ import path from "node:path" +import { pathToFileURL } from "node:url" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { type Tool } from "ai" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" @@ -9,6 +10,7 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { + ListRootsRequestSchema, type LoggingMessageNotification, LoggingMessageNotificationSchema, type Tool as MCPToolDef, @@ -42,7 +44,7 @@ const CLIENT_OPTIONS = { // https://github.com/anomalyco/opencode/issues/23066 // elicitation: {}, // https://github.com/anomalyco/opencode/issues/2308 - // roots: {}, + roots: {}, // https://github.com/anomalyco/opencode/issues/28567 // tasks: {}, }, @@ -82,6 +84,14 @@ export class NotFoundError extends Schema.TaggedErrorClass()("MCP type MCPClient = Client +function createClient(directory: string) { + const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS) + client.setRequestHandler(ListRootsRequestSchema, () => + Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }), + ) + return client +} + const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({ identifier: "MCPStatusConnected", }) @@ -192,19 +202,21 @@ export const layer = Layer.effect( * Connect a client via the given transport with resource safety: * on failure the transport is closed; on success the caller owns it. */ - const connectTransport = (transport: Transport, timeout: number) => - Effect.acquireUseRelease( + const connectTransport = Effect.fn("MCP.connectTransport")(function* (transport: Transport, timeout: number) { + const directory = yield* InstanceState.directory + return yield* Effect.acquireUseRelease( Effect.succeed(transport), (t) => Effect.tryPromise({ try: () => { - const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS) + const client = createClient(directory) return withTimeout(client.connect(t), timeout).then(() => client) }, catch: (e) => (e instanceof Error ? e : new Error(String(e))), }), (t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void), ) + }) const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } } @@ -777,10 +789,11 @@ export const layer = Layer.effect( authProvider, requestInit: mcpConfig.headers ? { headers: mcpConfig.headers } : undefined, }) + const directory = yield* InstanceState.directory return yield* Effect.tryPromise({ try: () => { - const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS) + const client = createClient(directory) return client .connect(transport) .then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 763f6a319..34304624e 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,6 +1,7 @@ import path from "node:path" +import { pathToFileURL } from "node:url" import { expect, mock, beforeEach } from "bun:test" -import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" +import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" @@ -38,6 +39,8 @@ interface MockClientState { { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string } > closed: boolean + clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } } + requestHandlers: Map Promise> notificationHandlers: Map any> } @@ -75,6 +78,7 @@ function getOrCreateClientState(name?: string): MockClientState { promptPages: {}, resourcePages: {}, closed: false, + requestHandlers: new Map(), notificationHandlers: new Map(), } clientStates.set(key, state) @@ -149,8 +153,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ _state!: MockClientState transport: any - constructor(_opts: any) { + constructor(_info: any, options?: MockClientState["clientOptions"]) { clientCreateCount++ + this._state = getOrCreateClientState(lastCreatedClientName) + this._state.clientOptions = options } async connect(transport: { start: () => Promise }) { @@ -160,6 +166,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ 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) } @@ -251,6 +261,28 @@ function statusName(status: Record | MCPNS.Status, server: 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", () => diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 881790e1e..9b46853c7 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -87,6 +87,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ // 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() } diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index ab807e439..b0dfb06e6 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -89,6 +89,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ // Mock the MCP SDK Client to trigger OAuth flow void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ Client: class MockClient { + setRequestHandler() {} + async connect(transport: { start: () => Promise }) { await transport.start() }