diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 023eab520ee1..8c0d3b465f68 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -13,6 +13,8 @@ import { ListRootsRequestSchema, type LoggingMessageNotification, LoggingMessageNotificationSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" @@ -60,7 +62,8 @@ export const Resource = Schema.Struct({ export type Resource = Schema.Schema.Type export const ToolsChanged = McpEvent.ToolsChanged - +export const ResourcesChanged = McpEvent.ResourcesChanged +export const ResourceUpdated = McpEvent.ResourceUpdated export const BrowserOpenFailed = McpEvent.BrowserOpenFailed export const Failed = NamedError.create("MCPFailed", { @@ -72,6 +75,7 @@ export class NotFoundError extends Schema.TaggedErrorClass()("MCP }) {} type MCPClient = Client +type CompleteParams = Parameters[0] function createClient(directory: string) { const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS) @@ -146,6 +150,7 @@ interface State { clients: Record defs: Record instructions: Record + resourceSubscriptions: Record> } export interface ServerInstructions { @@ -176,6 +181,14 @@ export interface Interface { clientName: string, resourceUri: string, ) => Effect.Effect> | undefined> + readonly complete: ( + clientName: string, + ref: CompleteParams["ref"], + argument: CompleteParams["argument"], + context?: CompleteParams["context"], + ) => Effect.Effect> | undefined> + readonly subscribeResource: (clientName: string, resourceUri: string) => Effect.Effect + readonly unsubscribeResource: (clientName: string, resourceUri: string) => Effect.Effect readonly startAuth: ( mcpName: string, ) => Effect.Effect<{ authorizationUrl: string; oauthState: string }, NotFoundError> @@ -437,6 +450,7 @@ export const layer = Layer.effect( delete s.clients[name] delete s.defs[name] delete s.instructions[name] + delete s.resourceSubscriptions[name] s.status[name] = { status: "failed", error: "Connection closed" } bridge.fork( Effect.logWarning("MCP connection closed", { server: name }).pipe( @@ -450,17 +464,33 @@ export const layer = Layer.effect( bridge.promise(serverLog(name, notification.params)), ) - if (!client.getServerCapabilities()?.tools) return - client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { - if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + const capabilities = client.getServerCapabilities() + if (capabilities?.resources) { + client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => { + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + await bridge.promise(events.publish(ResourcesChanged, { server: name }).pipe(Effect.ignore)) + }) + if (capabilities.resources.subscribe) { + client.setNotificationHandler(ResourceUpdatedNotificationSchema, async (notification) => { + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + await bridge.promise( + events.publish(ResourceUpdated, { server: name, uri: notification.params.uri }).pipe(Effect.ignore), + ) + }) + } + } + if (capabilities?.tools) { + client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - const listed = await bridge.promise(McpCatalog.defs(client, timeout)) - if (!listed) return - if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + const listed = await bridge.promise(McpCatalog.defs(client, timeout)) + if (!listed) return + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - s.defs[name] = listed - await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) - }) + s.defs[name] = listed + await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) + }) + } } function serverLog(name: string, params: LoggingMessageNotification["params"]) { @@ -492,6 +522,7 @@ export const layer = Layer.effect( clients: {}, defs: {}, instructions: {}, + resourceSubscriptions: {}, } yield* Effect.forEach( @@ -514,6 +545,7 @@ export const layer = Layer.effect( s.clients[key] = result.mcpClient s.defs[key] = result.defs! if (result.instructions) s.instructions[key] = result.instructions + s.resourceSubscriptions[key] = new Set() watch(s, key, result.mcpClient, bridge, mcp.timeout) } }), @@ -526,6 +558,7 @@ export const layer = Layer.effect( s.clients = {} s.defs = {} s.instructions = {} + s.resourceSubscriptions = {} yield* Effect.forEach( clients, (client) => @@ -556,6 +589,7 @@ export const layer = Layer.effect( delete s.clients[name] delete s.defs[name] delete s.instructions[name] + delete s.resourceSubscriptions[name] if (!client) return Effect.void return Effect.tryPromise(() => client.close()).pipe(Effect.ignore) } @@ -575,6 +609,7 @@ export const layer = Layer.effect( s.defs[name] = listed if (instructions) s.instructions[name] = instructions else delete s.instructions[name] + s.resourceSubscriptions[name] = new Set() watch(s, name, client, bridge, timeout) if (previous) yield* Effect.tryPromise(() => previous.close()).pipe(Effect.ignore) return s.status[name] @@ -771,13 +806,70 @@ export const layer = Layer.effect( ) }) + const subscribeResource = Effect.fn("MCP.subscribeResource")(function* (clientName: string, resourceUri: string) { + const s = yield* InstanceState.get(state) + if (s.resourceSubscriptions[clientName]?.has(resourceUri)) return + + const result = yield* withClient( + clientName, + async (client, timeout) => { + if (!client.getServerCapabilities()?.resources?.subscribe) return false + await client.subscribeResource({ uri: resourceUri }, { timeout }) + return true + }, + "subscribeResource", + { resourceUri }, + ) + if (result) (s.resourceSubscriptions[clientName] ??= new Set()).add(resourceUri) + }) + const readResource = Effect.fn("MCP.readResource")(function* (clientName: string, resourceUri: string) { - return yield* withClient( + const result = yield* withClient( clientName, (client, timeout) => client.readResource({ uri: resourceUri }, { timeout }), "readResource", { resourceUri }, ) + if (result) yield* subscribeResource(clientName, resourceUri).pipe(Effect.ignore) + return result + }) + + const complete = Effect.fn("MCP.complete")(function* ( + clientName: string, + ref: CompleteParams["ref"], + argument: CompleteParams["argument"], + context?: CompleteParams["context"], + ) { + return yield* withClient( + clientName, + (client, timeout) => { + if (!client.getServerCapabilities()?.completions) return Promise.resolve(undefined) + return client.complete(context === undefined ? { ref, argument } : { ref, argument, context }, { timeout }) + }, + "complete", + { ref, argumentName: argument.name }, + ) + }) + + const unsubscribeResource = Effect.fn("MCP.unsubscribeResource")(function* ( + clientName: string, + resourceUri: string, + ) { + const s = yield* InstanceState.get(state) + const subscribed = s.resourceSubscriptions[clientName] + if (!subscribed?.has(resourceUri)) return + + const result = yield* withClient( + clientName, + async (client, timeout) => { + if (!client.getServerCapabilities()?.resources?.subscribe) return false + await client.unsubscribeResource({ uri: resourceUri }, { timeout }) + return true + }, + "unsubscribeResource", + { resourceUri }, + ) + if (result) subscribed.delete(resourceUri) }) const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) { @@ -987,6 +1079,9 @@ export const layer = Layer.effect( disconnect, getPrompt, readResource, + complete, + subscribeResource, + unsubscribeResource, startAuth, authenticate, finishAuth, diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index ec82d4f47f1f..7e0f3a1df54d 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,17 +1,23 @@ import path from "node:path" import { pathToFileURL } from "node:url" import { expect, mock, beforeEach } from "bun:test" -import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" -import { Cause, Effect, Exit } from "effect" +import { + ListRootsRequestSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + ToolListChangedNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js" +import { Cause, Deferred, Effect, Exit } from "effect" +import { GlobalBus } from "@/bus/global" import type { MCP as MCPNS } from "../../src/mcp/index" -import { testEffect } from "../lib/effect" +import { awaitWithTimeout, testEffect } from "../lib/effect" import { TestInstance } from "../fixture/fixture" // --- Mock infrastructure --- // Per-client state for controlling mock behavior interface MockClientState { - capabilities: { tools?: object; prompts?: object; resources?: object } + capabilities: { tools?: object; prompts?: object; resources?: object; completions?: object } capabilitiesShouldThrow: boolean instructions?: string tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> @@ -19,13 +25,25 @@ interface MockClientState { listPromptsCalls: number listResourcesCalls: number listResourceTemplatesCalls: number + completeCalls: number + readResourceCalls: number + subscribedResources: string[] + unsubscribedResources: string[] getPromptTimeout?: number + completeTimeout?: number readResourceTimeout?: number + completeRequests: Array<{ + ref: { type: "ref/prompt"; name: string } | { type: "ref/resource"; uri: string } + argument: { name: string; value: string } + context?: { arguments?: Record } + }> requestCalls: number listToolsShouldFail: boolean listToolsError: string listPromptsShouldFail: boolean listResourcesShouldFail: boolean + listResourceTemplatesShouldFail: boolean + completeShouldFail: boolean prompts: Array<{ name: string; description?: string }> resources: Array<{ name: string; uri: string; description?: string }> resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }> @@ -75,11 +93,18 @@ function getOrCreateClientState(name?: string): MockClientState { listPromptsCalls: 0, listResourcesCalls: 0, listResourceTemplatesCalls: 0, + completeCalls: 0, + readResourceCalls: 0, + subscribedResources: [], + unsubscribedResources: [], + completeRequests: [], requestCalls: 0, listToolsShouldFail: false, listToolsError: "listTools failed", listPromptsShouldFail: false, listResourcesShouldFail: false, + listResourceTemplatesShouldFail: false, + completeShouldFail: false, prompts: [], resources: [], resourceTemplates: [], @@ -240,21 +265,52 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ async listResourceTemplates(params?: { cursor?: string }) { if (this._state) this._state.listResourceTemplatesCalls++ + if (this._state?.listResourceTemplatesShouldFail) { + throw new Error("listResourceTemplates failed") + } const page = this._state?.resourceTemplatePages[params === undefined ? "initial" : (params.cursor ?? "")] if (page) return page return { resourceTemplates: this._state?.resourceTemplates ?? [] } } + async complete( + params: { + ref: { type: "ref/prompt"; name: string } | { type: "ref/resource"; uri: string } + argument: { name: string; value: string } + context?: { arguments?: Record } + }, + options?: { timeout?: number }, + ) { + if (this._state) { + this._state.completeCalls++ + this._state.completeTimeout = options?.timeout + this._state.completeRequests.push(params) + } + if (this._state?.completeShouldFail) { + throw new Error("complete failed") + } + return { completion: { values: [`${params.argument.value}-one`, `${params.argument.value}-two`], total: 2 } } + } + async getPrompt(_params: unknown, options?: { timeout?: number }) { if (this._state) this._state.getPromptTimeout = options?.timeout return { messages: [] } } async readResource(params: { uri: string }, options?: { timeout?: number }) { + if (this._state) this._state.readResourceCalls++ if (this._state) this._state.readResourceTimeout = options?.timeout return { contents: [{ uri: params.uri, text: "test" }] } } + async subscribeResource(params: { uri: string }) { + this._state?.subscribedResources.push(params.uri) + } + + async unsubscribeResource(params: { uri: string }) { + this._state?.unsubscribedResources.push(params.uri) + } + async close() { if (this._state) this._state.closed = true } @@ -407,7 +463,7 @@ it.instance( ) it.instance( - "follows cursors when listing tools, prompts, and resources", + "follows cursors when listing tools, prompts, resources, and resource templates", () => MCP.Service.use((mcp: MCPNS.Interface) => Effect.gen(function* () { @@ -546,6 +602,194 @@ it.instance( { config: { mcp: {} } }, ) +it.instance( + "resource list change notifications publish a resource change event", + () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + const seen = yield* Deferred.make() + const listener = (event: { payload: { type?: string; properties?: { server?: string } } }) => { + if (event.payload.type === "mcp.resources.changed" && event.payload.properties?.server) + Deferred.doneUnsafe(seen, Effect.succeed(event.payload.properties.server)) + } + yield* Effect.acquireRelease( + Effect.sync(() => GlobalBus.on("event", listener)), + () => Effect.sync(() => GlobalBus.off("event", listener)), + ) + + lastCreatedClientName = "resource-notify-server" + const serverState = getOrCreateClientState("resource-notify-server") + serverState.capabilities = { resources: {} } + + yield* mcp.add("resource-notify-server", { + type: "local", + command: ["echo", "test"], + }) + + const handler = serverState.notificationHandlers.get(ResourceListChangedNotificationSchema) + expect(handler).toBeDefined() + yield* Effect.promise(() => handler?.()) + + expect(yield* awaitWithTimeout(Deferred.await(seen), "mcp.resources.changed event was not published")).toBe( + "resource-notify-server", + ) + }), + { config: { mcp: {} } }, +) + +it.instance( + "readResource subscribes once when the server supports resource subscriptions", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "resource-subscribe-server" + const serverState = getOrCreateClientState("resource-subscribe-server") + serverState.capabilities = { resources: { subscribe: true } } + + yield* mcp.add("resource-subscribe-server", { + type: "local", + command: ["echo", "test"], + timeout: 2500, + }) + + const first = yield* mcp.readResource("resource-subscribe-server", "docs://readme") + const second = yield* mcp.readResource("resource-subscribe-server", "docs://readme") + + expect(first?.contents).toEqual([{ uri: "docs://readme", text: "test" }]) + expect(second?.contents).toEqual([{ uri: "docs://readme", text: "test" }]) + expect(serverState.subscribedResources).toEqual(["docs://readme"]) + expect(serverState.readResourceCalls).toBe(2) + expect(serverState.readResourceTimeout).toBe(2500) + }), + ), + { config: { mcp: {}, experimental: { mcp_timeout: 5000 } } }, +) + +it.instance( + "subscribeResource exposes explicit resource subscriptions", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "resource-selected-server" + const serverState = getOrCreateClientState("resource-selected-server") + serverState.capabilities = { resources: { subscribe: true } } + + yield* mcp.add("resource-selected-server", { + type: "local", + command: ["echo", "test"], + }) + + yield* mcp.subscribeResource("resource-selected-server", "docs://selected") + yield* mcp.readResource("resource-selected-server", "docs://selected") + + expect(serverState.subscribedResources).toEqual(["docs://selected"]) + expect(serverState.readResourceCalls).toBe(1) + }), + ), + { config: { mcp: {} } }, +) + +it.instance( + "readResource does not subscribe when the server does not support resource subscriptions", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "resource-read-server" + const serverState = getOrCreateClientState("resource-read-server") + serverState.capabilities = { resources: {} } + + yield* mcp.add("resource-read-server", { + type: "local", + command: ["echo", "test"], + }) + + yield* mcp.readResource("resource-read-server", "docs://readme") + + expect(serverState.subscribedResources).toEqual([]) + expect(serverState.readResourceCalls).toBe(1) + }), + ), + { config: { mcp: {} } }, +) + +it.instance( + "unsubscribeResource unsubscribes explicit resource subscriptions", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "resource-unsubscribe-server" + const serverState = getOrCreateClientState("resource-unsubscribe-server") + serverState.capabilities = { resources: { subscribe: true } } + + yield* mcp.add("resource-unsubscribe-server", { + type: "local", + command: ["echo", "test"], + }) + + yield* mcp.readResource("resource-unsubscribe-server", "docs://readme") + yield* mcp.unsubscribeResource("resource-unsubscribe-server", "docs://readme") + yield* mcp.readResource("resource-unsubscribe-server", "docs://readme") + + expect(serverState.subscribedResources).toEqual(["docs://readme", "docs://readme"]) + expect(serverState.unsubscribedResources).toEqual(["docs://readme"]) + }), + ), + { config: { mcp: {} } }, +) + +it.instance( + "resource update notifications publish server and URI for the current client only", + () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + const seen = yield* Deferred.make<{ server: string; uri: string }>() + const listener = (event: { payload: { type?: string; properties?: { server?: string; uri?: string } } }) => { + if ( + event.payload.type === "mcp.resource.updated" && + event.payload.properties?.server && + event.payload.properties.uri + ) { + Deferred.doneUnsafe( + seen, + Effect.succeed({ server: event.payload.properties.server, uri: event.payload.properties.uri }), + ) + } + } + yield* Effect.acquireRelease( + Effect.sync(() => GlobalBus.on("event", listener)), + () => Effect.sync(() => GlobalBus.off("event", listener)), + ) + + lastCreatedClientName = "resource-update-server-old" + const oldState = getOrCreateClientState("resource-update-server-old") + oldState.capabilities = { resources: { subscribe: true } } + yield* mcp.add("resource-update-server", { type: "local", command: ["echo", "test"] }) + const oldHandler = oldState.notificationHandlers.get(ResourceUpdatedNotificationSchema) + + lastCreatedClientName = "resource-update-server-new" + const newState = getOrCreateClientState("resource-update-server-new") + newState.capabilities = { resources: { subscribe: true } } + yield* mcp.add("resource-update-server", { type: "local", command: ["echo", "test"] }) + const newHandler = newState.notificationHandlers.get(ResourceUpdatedNotificationSchema) + + expect(oldHandler).toBeDefined() + expect(newHandler).toBeDefined() + yield* Effect.promise(() => + oldHandler!({ method: "notifications/resources/updated", params: { uri: "docs://stale" } }), + ) + yield* Effect.promise(() => + newHandler!({ method: "notifications/resources/updated", params: { uri: "docs://current" } }), + ) + + expect(yield* awaitWithTimeout(Deferred.await(seen), "mcp.resource.updated event was not published")).toEqual({ + server: "resource-update-server", + uri: "docs://current", + }) + expect(newState.readResourceCalls).toBe(0) + }), + { config: { mcp: {} } }, +) + // ======================================================================== // Test: connect() / disconnect() lifecycle // ======================================================================== @@ -912,6 +1156,83 @@ it.instance( }, ) +it.instance( + "resourceTemplates() returns templates from connected resource servers", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "resource-template-server" + const serverState = getOrCreateClientState("resource-template-server") + serverState.capabilities = { resources: {} } + serverState.resourceTemplates = [ + { + name: "repo-file", + uriTemplate: "repo://{owner}/{repo}/files/{path}", + description: "Repository file", + }, + ] + + yield* mcp.add("resource-template-server", { + type: "local", + command: ["echo", "test"], + }) + + const templates = yield* mcp.resourceTemplates() + const key = "resource-template-server:repo://{owner}/{repo}/files/{path}" + expect(Object.keys(templates)).toEqual([key]) + expect(templates[key]?.uriTemplate).toBe("repo://{owner}/{repo}/files/{path}") + expect(templates[key]?.client).toBe("resource-template-server") + expect(serverState.listResourceTemplatesCalls).toBe(1) + }), + ), + { config: { mcp: {} } }, +) + +it.instance( + "resourceTemplates() skips servers without resource capability", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "tool-only-template-server" + const serverState = getOrCreateClientState("tool-only-template-server") + serverState.capabilities = { tools: {} } + serverState.resourceTemplates = [{ name: "hidden", uriTemplate: "hidden://{id}" }] + + yield* mcp.add("tool-only-template-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(yield* mcp.resourceTemplates()).toEqual({}) + expect(serverState.listResourceTemplatesCalls).toBe(0) + }), + ), + { config: { mcp: {} } }, +) + +it.instance( + "resourceTemplates() returns an empty catalog when listing fails", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "failing-resource-template-server" + const serverState = getOrCreateClientState("failing-resource-template-server") + serverState.capabilities = { resources: {} } + serverState.listResourceTemplatesShouldFail = true + serverState.resourceTemplates = [{ name: "hidden", uriTemplate: "hidden://{id}" }] + + yield* mcp.add("failing-resource-template-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(yield* mcp.resourceTemplates()).toEqual({}) + expect(serverState.listResourceTemplatesCalls).toBe(1) + }), + ), + { config: { mcp: {} } }, +) + it.instance( "uses per-server timeouts for prompt and resource requests", () => @@ -935,6 +1256,108 @@ it.instance( { config: { mcp: {}, experimental: { mcp_timeout: 5000 } } }, ) +it.instance( + "complete() forwards prompt and resource template references with context", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "completion-server" + const serverState = getOrCreateClientState("completion-server") + serverState.capabilities = { prompts: {}, resources: {}, completions: {} } + + yield* mcp.add("completion-server", { + type: "local", + command: ["echo", "test"], + timeout: 2500, + }) + + const prompt = yield* mcp.complete( + "completion-server", + { type: "ref/prompt", name: "review" }, + { name: "path", value: "src" }, + ) + const resource = yield* mcp.complete( + "completion-server", + { type: "ref/resource", uri: "repo://{owner}/{repo}/files/{path}" }, + { name: "path", value: "src" }, + { arguments: { owner: "opencode-ai", repo: "opencode" } }, + ) + + expect(prompt?.completion.values).toEqual(["src-one", "src-two"]) + expect(resource?.completion.values).toEqual(["src-one", "src-two"]) + expect(serverState.completeCalls).toBe(2) + expect(serverState.completeTimeout).toBe(2500) + expect(serverState.completeRequests).toEqual([ + { + ref: { type: "ref/prompt", name: "review" }, + argument: { name: "path", value: "src" }, + }, + { + ref: { type: "ref/resource", uri: "repo://{owner}/{repo}/files/{path}" }, + argument: { name: "path", value: "src" }, + context: { arguments: { owner: "opencode-ai", repo: "opencode" } }, + }, + ]) + }), + ), + { config: { mcp: {}, experimental: { mcp_timeout: 5000 } } }, +) + +it.instance( + "complete() skips servers without completion capability", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "no-completion-server" + const serverState = getOrCreateClientState("no-completion-server") + serverState.capabilities = { prompts: {} } + + yield* mcp.add("no-completion-server", { + type: "local", + command: ["echo", "test"], + }) + + const result = yield* mcp.complete( + "no-completion-server", + { type: "ref/prompt", name: "review" }, + { name: "path", value: "src" }, + ) + + expect(result).toBeUndefined() + expect(serverState.completeCalls).toBe(0) + }), + ), + { config: { mcp: {} } }, +) + +it.instance( + "complete() returns undefined when completion fails", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "failing-completion-server" + const serverState = getOrCreateClientState("failing-completion-server") + serverState.capabilities = { completions: {} } + serverState.completeShouldFail = true + + yield* mcp.add("failing-completion-server", { + type: "local", + command: ["echo", "test"], + }) + + const result = yield* mcp.complete( + "failing-completion-server", + { type: "ref/prompt", name: "review" }, + { name: "path", value: "src" }, + ) + + expect(result).toBeUndefined() + expect(serverState.completeCalls).toBe(1) + }), + ), + { config: { mcp: {} } }, +) + it.instance( "resource-only servers connect without listing tools", () => diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 460f1acc7433..326c3e70b1a5 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -125,6 +125,9 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) { disconnect: () => Effect.void, getPrompt: () => Effect.succeed(undefined), readResource: () => Effect.succeed(undefined), + complete: () => Effect.succeed(undefined), + subscribeResource: () => Effect.void, + unsubscribeResource: () => Effect.void, startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"), finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 313e5cf9c78c..ff796e895e3f 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -47,6 +47,9 @@ const mcp = Layer.succeed( disconnect: () => Effect.void, getPrompt: () => Effect.succeed(undefined), readResource: () => Effect.succeed(undefined), + complete: () => Effect.succeed(undefined), + subscribeResource: () => Effect.void, + unsubscribeResource: () => Effect.void, startAuth: () => Effect.die("unexpected MCP auth"), authenticate: () => Effect.die("unexpected MCP auth"), finishAuth: () => Effect.die("unexpected MCP auth"), diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts index 1d050df92738..7f6a131c1968 100644 --- a/packages/schema/src/mcp-event.ts +++ b/packages/schema/src/mcp-event.ts @@ -10,6 +10,21 @@ export const ToolsChanged = Event.define({ }, }) +export const ResourcesChanged = Event.define({ + type: "mcp.resources.changed", + schema: { + server: Schema.String, + }, +}) + +export const ResourceUpdated = Event.define({ + type: "mcp.resource.updated", + schema: { + server: Schema.String, + uri: Schema.String, + }, +}) + export const BrowserOpenFailed = Event.define({ type: "mcp.browser.open.failed", schema: { @@ -18,4 +33,4 @@ export const BrowserOpenFailed = Event.define({ }, }) -export const Definitions = Event.inventory(ToolsChanged, BrowserOpenFailed) +export const Definitions = Event.inventory(ToolsChanged, ResourcesChanged, ResourceUpdated, BrowserOpenFailed) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ae610a3f129c..ef46ec70da18 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -76,6 +76,8 @@ export type Event = | EventTuiToastShow2 | EventTuiSessionSelect2 | EventMcpToolsChanged + | EventMcpResourcesChanged + | EventMcpResourceUpdated | EventMcpBrowserOpenFailed | EventCommandExecuted | EventProjectUpdated @@ -1458,6 +1460,21 @@ export type GlobalEvent = { server: string } } + | { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } + } + | { + id: string + type: "mcp.resource.updated" + properties: { + server: string + uri: string + } + } | { id: string type: "mcp.browser.open.failed" @@ -7431,6 +7448,23 @@ export type EventMcpToolsChanged = { } } +export type EventMcpResourcesChanged = { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } +} + +export type EventMcpResourceUpdated = { + id: string + type: "mcp.resource.updated" + properties: { + server: string + uri: string + } +} + export type EventMcpBrowserOpenFailed = { id: string type: "mcp.browser.open.failed"