Skip to content

Commit f55a931

Browse files
authored
feat(mcp): support client roots (#32230)
1 parent 42f339c commit f55a931

4 files changed

Lines changed: 56 additions & 7 deletions

File tree

packages/opencode/src/mcp/index.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from "node:path"
2+
import { pathToFileURL } from "node:url"
23
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
34
import { type Tool } from "ai"
45
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
@@ -9,6 +10,7 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
910
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
1011
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
1112
import {
13+
ListRootsRequestSchema,
1214
type LoggingMessageNotification,
1315
LoggingMessageNotificationSchema,
1416
type Tool as MCPToolDef,
@@ -42,7 +44,7 @@ const CLIENT_OPTIONS = {
4244
// https://github.com/anomalyco/opencode/issues/23066
4345
// elicitation: {},
4446
// https://github.com/anomalyco/opencode/issues/2308
45-
// roots: {},
47+
roots: {},
4648
// https://github.com/anomalyco/opencode/issues/28567
4749
// tasks: {},
4850
},
@@ -82,6 +84,14 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP
8284

8385
type MCPClient = Client
8486

87+
function createClient(directory: string) {
88+
const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS)
89+
client.setRequestHandler(ListRootsRequestSchema, () =>
90+
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
91+
)
92+
return client
93+
}
94+
8595
const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
8696
identifier: "MCPStatusConnected",
8797
})
@@ -192,19 +202,21 @@ export const layer = Layer.effect(
192202
* Connect a client via the given transport with resource safety:
193203
* on failure the transport is closed; on success the caller owns it.
194204
*/
195-
const connectTransport = (transport: Transport, timeout: number) =>
196-
Effect.acquireUseRelease(
205+
const connectTransport = Effect.fn("MCP.connectTransport")(function* (transport: Transport, timeout: number) {
206+
const directory = yield* InstanceState.directory
207+
return yield* Effect.acquireUseRelease(
197208
Effect.succeed(transport),
198209
(t) =>
199210
Effect.tryPromise({
200211
try: () => {
201-
const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS)
212+
const client = createClient(directory)
202213
return withTimeout(client.connect(t), timeout).then(() => client)
203214
},
204215
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
205216
}),
206217
(t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void),
207218
)
219+
})
208220

209221
const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } }
210222

@@ -777,10 +789,11 @@ export const layer = Layer.effect(
777789
authProvider,
778790
requestInit: mcpConfig.headers ? { headers: mcpConfig.headers } : undefined,
779791
})
792+
const directory = yield* InstanceState.directory
780793

781794
return yield* Effect.tryPromise({
782795
try: () => {
783-
const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS)
796+
const client = createClient(directory)
784797
return client
785798
.connect(transport)
786799
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)

packages/opencode/test/mcp/lifecycle.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from "node:path"
2+
import { pathToFileURL } from "node:url"
23
import { expect, mock, beforeEach } from "bun:test"
3-
import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
4+
import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
45
import { Cause, Effect, Exit } from "effect"
56
import type { MCP as MCPNS } from "../../src/mcp/index"
67
import { testEffect } from "../lib/effect"
@@ -38,6 +39,8 @@ interface MockClientState {
3839
{ resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string }
3940
>
4041
closed: boolean
42+
clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
43+
requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
4144
notificationHandlers: Map<unknown, (...args: any[]) => any>
4245
}
4346

@@ -75,6 +78,7 @@ function getOrCreateClientState(name?: string): MockClientState {
7578
promptPages: {},
7679
resourcePages: {},
7780
closed: false,
81+
requestHandlers: new Map(),
7882
notificationHandlers: new Map(),
7983
}
8084
clientStates.set(key, state)
@@ -149,8 +153,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
149153
_state!: MockClientState
150154
transport: any
151155

152-
constructor(_opts: any) {
156+
constructor(_info: any, options?: MockClientState["clientOptions"]) {
153157
clientCreateCount++
158+
this._state = getOrCreateClientState(lastCreatedClientName)
159+
this._state.clientOptions = options
154160
}
155161

156162
async connect(transport: { start: () => Promise<void> }) {
@@ -160,6 +166,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
160166
this._state = getOrCreateClientState(lastCreatedClientName)
161167
}
162168

169+
setRequestHandler(schema: unknown, handler: (...args: any[]) => Promise<any>) {
170+
this._state.requestHandlers.set(schema, handler)
171+
}
172+
163173
setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
164174
this._state?.notificationHandlers.set(schema, handler)
165175
}
@@ -251,6 +261,28 @@ function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server:
251261
return status[server]?.status
252262
}
253263

264+
it.instance(
265+
"advertises and lists the instance directory as its root",
266+
() =>
267+
MCP.Service.use((mcp: MCPNS.Interface) =>
268+
Effect.gen(function* () {
269+
const { directory } = yield* TestInstance
270+
lastCreatedClientName = "roots"
271+
yield* mcp.add("roots", { type: "local", command: ["echo", "test"] })
272+
273+
const state = getOrCreateClientState("roots")
274+
expect(state.clientOptions?.capabilities?.roots).toEqual({})
275+
expect(state.clientOptions?.capabilities?.roots?.listChanged).toBeUndefined()
276+
277+
const handler = state.requestHandlers.get(ListRootsRequestSchema)
278+
expect(handler).toBeDefined()
279+
const result = yield* Effect.promise(() => handler?.() ?? Promise.reject(new Error("roots handler missing")))
280+
expect(result).toEqual({ roots: [{ uri: pathToFileURL(directory).href }] })
281+
}),
282+
),
283+
{ config: { mcp: {} } },
284+
)
285+
254286
it.instance(
255287
"local mcp cwd resolves relative paths against instance directory",
256288
() =>

packages/opencode/test/mcp/oauth-auto-connect.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
8787
// Mock the MCP SDK Client
8888
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
8989
Client: class MockClient {
90+
setRequestHandler() {}
91+
9092
async connect(transport: { start: () => Promise<void> }) {
9193
await transport.start()
9294
}

packages/opencode/test/mcp/oauth-browser.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
8989
// Mock the MCP SDK Client to trigger OAuth flow
9090
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
9191
Client: class MockClient {
92+
setRequestHandler() {}
93+
9294
async connect(transport: { start: () => Promise<void> }) {
9395
await transport.start()
9496
}

0 commit comments

Comments
 (0)