diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 08d58118c992..3cc4939f64d7 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" @@ -66,6 +67,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: { @@ -439,17 +447,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 34304624e680..e59fa3fa7149 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 --- @@ -459,6 +464,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 // ========================================================================