diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 4128764b1318..4c75560a2198 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -11,6 +11,7 @@ import { ListToolsResultSchema, ToolSchema, type Tool as MCPToolDef, + ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" import { Config } from "@/config/config" @@ -56,6 +57,13 @@ export const ToolsChanged = EventV2.define({ }, }) +export const ResourcesChanged = EventV2.define({ + type: "mcp.resources.changed", + schema: { + server: Schema.String, + }, +}) + export const BrowserOpenFailed = EventV2.define({ type: "mcp.browser.open.failed", schema: { @@ -508,18 +516,27 @@ export const layer = Layer.effect( ) function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) { - if (!client.getServerCapabilities()?.tools) return - client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { - log.info("tools list changed notification received", { server: name }) - if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - - const listed = await bridge.promise(defs(name, 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)) - }) + const capabilities = client.getServerCapabilities() + if (capabilities?.tools) { + client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { + log.info("tools list changed notification received", { server: name }) + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + + const listed = await bridge.promise(defs(name, 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)) + }) + } + if (capabilities?.resources?.listChanged) { + client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => { + log.info("resources list changed notification received", { server: name }) + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + await bridge.promise(events.publish(ResourcesChanged, { server: name }).pipe(Effect.ignore)) + }) + } } const state = yield* InstanceState.make( diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 7bd5e4f00b4d..90bc1dde244a 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,6 +1,7 @@ import { expect, mock, beforeEach } from "bun:test" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" +import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { testEffect } from "../lib/effect" // --- Mock infrastructure --- @@ -610,6 +611,67 @@ it.instance( }, ) +it.instance( + "resource list change notifications publish for the active client", + () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + const received: string[] = [] + const listener = (event: GlobalEvent) => { + if (event.payload.type === MCP.ResourcesChanged.type) received.push(event.payload.properties.server) + } + GlobalBus.on("event", listener) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener))) + + lastCreatedClientName = "resource-server" + const firstState = getOrCreateClientState("resource-server") + firstState.capabilities = { resources: { listChanged: true } } + yield* mcp.add("resource-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(firstState.notificationHandlers.size).toBe(1) + const staleHandler = Array.from(firstState.notificationHandlers.values())[0] + + clientStates.delete("resource-server") + const activeState = getOrCreateClientState("resource-server") + activeState.capabilities = { resources: { listChanged: true } } + yield* mcp.add("resource-server", { + type: "local", + command: ["echo", "test"], + }) + + yield* Effect.promise(() => staleHandler?.()) + expect(received).toEqual([]) + + const activeHandler = Array.from(activeState.notificationHandlers.values())[0] + yield* Effect.promise(() => activeHandler?.()) + expect(received).toEqual(["resource-server"]) + }), + { config: { mcp: {} } }, +) + +it.instance( + "resource list change notifications require advertised support", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "resource-server" + const serverState = getOrCreateClientState("resource-server") + serverState.capabilities = { resources: {} } + + yield* mcp.add("resource-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(serverState.notificationHandlers.size).toBe(0) + }), + ), + { config: { mcp: {} } }, +) + it.instance( "resource-only servers connect without listing tools", () => diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9c57ccd15d29..cc7fb874752e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -74,6 +74,7 @@ export type Event = | EventTuiToastShow2 | EventTuiSessionSelect2 | EventMcpToolsChanged + | EventMcpResourcesChanged | EventMcpBrowserOpenFailed | EventCommandExecuted | EventProjectDirectoriesUpdated @@ -1463,6 +1464,13 @@ export type GlobalEvent = { server: string } } + | { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } + } | { id: string type: "mcp.browser.open.failed" @@ -5062,6 +5070,14 @@ export type EventMcpToolsChanged = { } } +export type EventMcpResourcesChanged = { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } +} + export type EventMcpBrowserOpenFailed = { id: string type: "mcp.browser.open.failed" diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 54e08219ea2d..2035685abb88 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -415,6 +415,13 @@ export const { break } + case "mcp.resources.changed": { + void sdk.client.experimental.resource + .list({ workspace }) + .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))) + break + } + case "vcs.branch.updated": { if (workspace === project.workspace.current()) { setStore("vcs", { branch: event.properties.branch })