feat(mcp): support client roots (#32230)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user