feat(mcp): support client roots (#32230)

This commit is contained in:
Aiden Cline
2026-06-13 19:13:15 -05:00
committed by GitHub
parent 42f339c681
commit f55a931f59
4 changed files with 56 additions and 7 deletions
+18 -5
View File
@@ -1,4 +1,5 @@
import path from "node:path" import path from "node:path"
import { pathToFileURL } from "node:url"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { type Tool } from "ai" import { type Tool } from "ai"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config" 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 { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import { import {
ListRootsRequestSchema,
type LoggingMessageNotification, type LoggingMessageNotification,
LoggingMessageNotificationSchema, LoggingMessageNotificationSchema,
type Tool as MCPToolDef, type Tool as MCPToolDef,
@@ -42,7 +44,7 @@ const CLIENT_OPTIONS = {
// https://github.com/anomalyco/opencode/issues/23066 // https://github.com/anomalyco/opencode/issues/23066
// elicitation: {}, // elicitation: {},
// https://github.com/anomalyco/opencode/issues/2308 // https://github.com/anomalyco/opencode/issues/2308
// roots: {}, roots: {},
// https://github.com/anomalyco/opencode/issues/28567 // https://github.com/anomalyco/opencode/issues/28567
// tasks: {}, // tasks: {},
}, },
@@ -82,6 +84,14 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP
type MCPClient = Client 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({ const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
identifier: "MCPStatusConnected", identifier: "MCPStatusConnected",
}) })
@@ -192,19 +202,21 @@ export const layer = Layer.effect(
* Connect a client via the given transport with resource safety: * Connect a client via the given transport with resource safety:
* on failure the transport is closed; on success the caller owns it. * on failure the transport is closed; on success the caller owns it.
*/ */
const connectTransport = (transport: Transport, timeout: number) => const connectTransport = Effect.fn("MCP.connectTransport")(function* (transport: Transport, timeout: number) {
Effect.acquireUseRelease( const directory = yield* InstanceState.directory
return yield* Effect.acquireUseRelease(
Effect.succeed(transport), Effect.succeed(transport),
(t) => (t) =>
Effect.tryPromise({ Effect.tryPromise({
try: () => { try: () => {
const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS) const client = createClient(directory)
return withTimeout(client.connect(t), timeout).then(() => client) return withTimeout(client.connect(t), timeout).then(() => client)
}, },
catch: (e) => (e instanceof Error ? e : new Error(String(e))), catch: (e) => (e instanceof Error ? e : new Error(String(e))),
}), }),
(t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void), (t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void),
) )
})
const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } } const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } }
@@ -777,10 +789,11 @@ export const layer = Layer.effect(
authProvider, authProvider,
requestInit: mcpConfig.headers ? { headers: mcpConfig.headers } : undefined, requestInit: mcpConfig.headers ? { headers: mcpConfig.headers } : undefined,
}) })
const directory = yield* InstanceState.directory
return yield* Effect.tryPromise({ return yield* Effect.tryPromise({
try: () => { try: () => {
const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS) const client = createClient(directory)
return client return client
.connect(transport) .connect(transport)
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult) .then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
+34 -2
View File
@@ -1,6 +1,7 @@
import path from "node:path" import path from "node:path"
import { pathToFileURL } from "node:url"
import { expect, mock, beforeEach } from "bun:test" 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 { Cause, Effect, Exit } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index" import type { MCP as MCPNS } from "../../src/mcp/index"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -38,6 +39,8 @@ interface MockClientState {
{ resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string } { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string }
> >
closed: boolean closed: boolean
clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
notificationHandlers: Map<unknown, (...args: any[]) => any> notificationHandlers: Map<unknown, (...args: any[]) => any>
} }
@@ -75,6 +78,7 @@ function getOrCreateClientState(name?: string): MockClientState {
promptPages: {}, promptPages: {},
resourcePages: {}, resourcePages: {},
closed: false, closed: false,
requestHandlers: new Map(),
notificationHandlers: new Map(), notificationHandlers: new Map(),
} }
clientStates.set(key, state) clientStates.set(key, state)
@@ -149,8 +153,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
_state!: MockClientState _state!: MockClientState
transport: any transport: any
constructor(_opts: any) { constructor(_info: any, options?: MockClientState["clientOptions"]) {
clientCreateCount++ clientCreateCount++
this._state = getOrCreateClientState(lastCreatedClientName)
this._state.clientOptions = options
} }
async connect(transport: { start: () => Promise<void> }) { async connect(transport: { start: () => Promise<void> }) {
@@ -160,6 +166,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
this._state = getOrCreateClientState(lastCreatedClientName) this._state = getOrCreateClientState(lastCreatedClientName)
} }
setRequestHandler(schema: unknown, handler: (...args: any[]) => Promise<any>) {
this._state.requestHandlers.set(schema, handler)
}
setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) { setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
this._state?.notificationHandlers.set(schema, handler) this._state?.notificationHandlers.set(schema, handler)
} }
@@ -251,6 +261,28 @@ function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server:
return status[server]?.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( it.instance(
"local mcp cwd resolves relative paths against instance directory", "local mcp cwd resolves relative paths against instance directory",
() => () =>
@@ -87,6 +87,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
// Mock the MCP SDK Client // Mock the MCP SDK Client
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class MockClient { Client: class MockClient {
setRequestHandler() {}
async connect(transport: { start: () => Promise<void> }) { async connect(transport: { start: () => Promise<void> }) {
await transport.start() await transport.start()
} }
@@ -89,6 +89,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
// Mock the MCP SDK Client to trigger OAuth flow // Mock the MCP SDK Client to trigger OAuth flow
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class MockClient { Client: class MockClient {
setRequestHandler() {}
async connect(transport: { start: () => Promise<void> }) { async connect(transport: { start: () => Promise<void> }) {
await transport.start() await transport.start()
} }