From f7e5a9f20ebda3bc4278b45d0cf24e752707acec Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Tue, 16 Jun 2026 06:54:07 +1000 Subject: [PATCH 1/4] feat(mcp): publish resource change events --- packages/opencode/src/mcp/index.ts | 29 +++++++----- packages/opencode/test/mcp/lifecycle.test.ts | 46 ++++++++++++++++++-- packages/schema/src/mcp-event.ts | 9 +++- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 2355a89ad771..d529839edcf4 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -13,6 +13,7 @@ import { ListRootsRequestSchema, type LoggingMessageNotification, LoggingMessageNotificationSchema, + ResourceListChangedNotificationSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" @@ -60,7 +61,7 @@ export const Resource = Schema.Struct({ export type Resource = Schema.Schema.Type export const ToolsChanged = McpEvent.ToolsChanged - +export const ResourcesChanged = McpEvent.ResourcesChanged export const BrowserOpenFailed = McpEvent.BrowserOpenFailed export const Failed = NamedError.create("MCPFailed", { @@ -450,17 +451,25 @@ 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?.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"]) { diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index ec82d4f47f1f..50b7b6b47f7f 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,10 +1,15 @@ 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, + 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 --- @@ -546,6 +551,41 @@ 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: {} } }, +) + // ======================================================================== // Test: connect() / disconnect() lifecycle // ======================================================================== diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts index 1d050df92738..dc2e5bcf8d6a 100644 --- a/packages/schema/src/mcp-event.ts +++ b/packages/schema/src/mcp-event.ts @@ -10,6 +10,13 @@ export const ToolsChanged = Event.define({ }, }) +export const ResourcesChanged = Event.define({ + type: "mcp.resources.changed", + schema: { + server: Schema.String, + }, +}) + export const BrowserOpenFailed = Event.define({ type: "mcp.browser.open.failed", schema: { @@ -18,4 +25,4 @@ export const BrowserOpenFailed = Event.define({ }, }) -export const Definitions = Event.inventory(ToolsChanged, BrowserOpenFailed) +export const Definitions = Event.inventory(ToolsChanged, ResourcesChanged, BrowserOpenFailed) From 4fecea7528aaa4d1d3cd536cd4ac0c026b480798 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Fri, 19 Jun 2026 14:25:35 +1000 Subject: [PATCH 2/4] feat(mcp): support resource subscriptions --- packages/opencode/src/mcp/index.ts | 63 ++++++- packages/opencode/test/mcp/lifecycle.test.ts | 175 +++++++++++++++++- packages/opencode/test/session/prompt.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + packages/schema/src/mcp-event.ts | 10 +- packages/sdk/js/src/v2/gen/types.gen.ts | 34 ++++ 6 files changed, 281 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index d529839edcf4..ce52519f4c0a 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -14,6 +14,7 @@ import { type LoggingMessageNotification, LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" @@ -62,6 +63,7 @@ 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", { @@ -147,6 +149,7 @@ interface State { clients: Record defs: Record instructions: Record + resourceSubscriptions: Record> } export interface ServerInstructions { @@ -177,6 +180,8 @@ export interface Interface { clientName: string, resourceUri: string, ) => 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> @@ -438,6 +443,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( @@ -457,6 +463,14 @@ export const layer = Layer.effect( 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 () => { @@ -501,6 +515,7 @@ export const layer = Layer.effect( clients: {}, defs: {}, instructions: {}, + resourceSubscriptions: {}, } yield* Effect.forEach( @@ -523,6 +538,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) } }), @@ -535,6 +551,7 @@ export const layer = Layer.effect( s.clients = {} s.defs = {} s.instructions = {} + s.resourceSubscriptions = {} yield* Effect.forEach( clients, (client) => @@ -565,6 +582,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) } @@ -584,6 +602,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] @@ -780,13 +799,53 @@ 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 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) { @@ -991,6 +1050,8 @@ export const layer = Layer.effect( disconnect, getPrompt, readResource, + subscribeResource, + unsubscribeResource, startAuth, authenticate, finishAuth, diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 50b7b6b47f7f..31b9a07edb13 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -4,6 +4,7 @@ import { expect, mock, beforeEach } from "bun:test" import { ListRootsRequestSchema, ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" import { Cause, Deferred, Effect, Exit } from "effect" @@ -24,6 +25,9 @@ interface MockClientState { listPromptsCalls: number listResourcesCalls: number listResourceTemplatesCalls: number + readResourceCalls: number + subscribedResources: string[] + unsubscribedResources: string[] getPromptTimeout?: number readResourceTimeout?: number requestCalls: number @@ -80,6 +84,9 @@ function getOrCreateClientState(name?: string): MockClientState { listPromptsCalls: 0, listResourcesCalls: 0, listResourceTemplatesCalls: 0, + readResourceCalls: 0, + subscribedResources: [], + unsubscribedResources: [], requestCalls: 0, listToolsShouldFail: false, listToolsError: "listTools failed", @@ -256,10 +263,19 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ } 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 } @@ -579,9 +595,162 @@ it.instance( expect(handler).toBeDefined() yield* Effect.promise(() => handler?.()) - expect( - yield* awaitWithTimeout(Deferred.await(seen), "mcp.resources.changed event was not published"), - ).toBe("resource-notify-server") + 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: {} } }, ) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 8893c370f8bc..19052c904c5b 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -125,6 +125,8 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) { disconnect: () => Effect.void, getPrompt: () => Effect.succeed(undefined), readResource: () => 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 0a5065837d0a..b06b5763c384 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -47,6 +47,8 @@ const mcp = Layer.succeed( disconnect: () => Effect.void, getPrompt: () => Effect.succeed(undefined), readResource: () => 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 dc2e5bcf8d6a..7f6a131c1968 100644 --- a/packages/schema/src/mcp-event.ts +++ b/packages/schema/src/mcp-event.ts @@ -17,6 +17,14 @@ export const ResourcesChanged = Event.define({ }, }) +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: { @@ -25,4 +33,4 @@ export const BrowserOpenFailed = Event.define({ }, }) -export const Definitions = Event.inventory(ToolsChanged, ResourcesChanged, 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 2db1871772ae..116df54e18cc 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 @@ -1492,6 +1494,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" @@ -6938,6 +6955,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" From 501958c3217525a97a04f29b69623261a85baca9 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Fri, 19 Jun 2026 15:34:13 +1000 Subject: [PATCH 3/4] feat(mcp): support templates and completion --- packages/opencode/src/mcp/index.ts | 25 +++ packages/opencode/test/mcp/lifecycle.test.ts | 156 +++++++++++++++++- packages/opencode/test/session/prompt.test.ts | 1 + .../test/session/snapshot-tool-race.test.ts | 1 + 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index ce52519f4c0a..d47a071c19f2 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -75,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) @@ -180,6 +181,12 @@ 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: ( @@ -827,6 +834,23 @@ export const layer = Layer.effect( 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, @@ -1050,6 +1074,7 @@ export const layer = Layer.effect( disconnect, getPrompt, readResource, + complete, subscribeResource, unsubscribeResource, startAuth, diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 31b9a07edb13..719c16be5088 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -17,7 +17,7 @@ import { TestInstance } from "../fixture/fixture" // 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 }> @@ -25,11 +25,18 @@ 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 @@ -84,9 +91,11 @@ 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", @@ -257,6 +266,22 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ 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) + } + 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: [] } @@ -428,7 +453,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* () { @@ -1121,6 +1146,59 @@ 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() + expect(Object.keys(templates)).toEqual(["resource-template-server:repo-file"]) + expect(templates["resource-template-server:repo-file"]?.uriTemplate).toBe("repo://{owner}/{repo}/files/{path}") + expect(templates["resource-template-server:repo-file"]?.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( "uses per-server timeouts for prompt and resource requests", () => @@ -1144,6 +1222,80 @@ 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( "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 19052c904c5b..189b2894c64d 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -125,6 +125,7 @@ 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"), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index b06b5763c384..4b0aaeb7d900 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -47,6 +47,7 @@ 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"), From 3e6bba5312f2d7a1e5e15bc999bfb699448d2c64 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Fri, 19 Jun 2026 21:11:24 +1000 Subject: [PATCH 4/4] test(mcp): cover templates completion failures --- packages/opencode/test/mcp/lifecycle.test.ts | 68 +++++++++++++++++++- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 719c16be5088..7e0f3a1df54d 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -42,6 +42,8 @@ interface MockClientState { 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 }> @@ -101,6 +103,8 @@ function getOrCreateClientState(name?: string): MockClientState { listToolsError: "listTools failed", listPromptsShouldFail: false, listResourcesShouldFail: false, + listResourceTemplatesShouldFail: false, + completeShouldFail: false, prompts: [], resources: [], resourceTemplates: [], @@ -261,6 +265,9 @@ 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 ?? [] } @@ -279,6 +286,9 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ 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 } } } @@ -1168,9 +1178,10 @@ it.instance( }) const templates = yield* mcp.resourceTemplates() - expect(Object.keys(templates)).toEqual(["resource-template-server:repo-file"]) - expect(templates["resource-template-server:repo-file"]?.uriTemplate).toBe("repo://{owner}/{repo}/files/{path}") - expect(templates["resource-template-server:repo-file"]?.client).toBe("resource-template-server") + 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) }), ), @@ -1199,6 +1210,29 @@ it.instance( { 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", () => @@ -1296,6 +1330,34 @@ it.instance( { 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", () =>