diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 6ef2ab780dc2..17cb17dc2574 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -7,6 +7,7 @@ import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" import { EventV2 } from "@opencode-ai/core/event" +import { EventV2Bridge } from "@/event-v2-bridge" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" @@ -68,6 +69,7 @@ export const layer = Layer.effect( const config = yield* Config.Service const mcp = yield* MCP.Service const skill = yield* Skill.Service + const events = yield* EventV2Bridge.Service const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { const cfg = yield* config.get() @@ -157,6 +159,11 @@ export const layer = Layer.effect( }) const state = yield* InstanceState.make((ctx) => init(ctx)) + const unsubscribe = yield* events.listen((event) => { + if (event.type !== MCP.PromptsChanged.type) return Effect.void + return InstanceState.invalidate(state) + }) + yield* Effect.addFinalizer(() => unsubscribe) const get = Effect.fn("Command.get")(function* (name: string) { const s = yield* InstanceState.get(state) @@ -176,6 +183,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Config.defaultLayer), Layer.provide(MCP.defaultLayer), Layer.provide(Skill.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), ) export * as Command from "." diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 4128764b1318..837cc1f62955 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -9,6 +9,7 @@ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, ListToolsResultSchema, + PromptListChangedNotificationSchema, ToolSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, @@ -56,6 +57,13 @@ export const ToolsChanged = EventV2.define({ }, }) +export const PromptsChanged = EventV2.define({ + type: "mcp.prompts.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?.prompts?.listChanged) { + client.setNotificationHandler(PromptListChangedNotificationSchema, async () => { + log.info("prompts list changed notification received", { server: name }) + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + await bridge.promise(events.publish(PromptsChanged, { 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..40e695475f2a 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,5 +1,5 @@ import { expect, mock, beforeEach } from "bun:test" -import { Cause, Effect, Exit } from "effect" +import { Cause, Effect, Exit, Layer } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" @@ -191,9 +191,10 @@ beforeEach(() => { // Import after mocks const { MCP } = await import("../../src/mcp/index") +const { Command } = await import("../../src/command/index") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") -const it = testEffect(MCP.defaultLayer) +const it = testEffect(Layer.merge(MCP.defaultLayer, Command.defaultLayer)) function statusName(status: Record | MCPNS.Status, server: string) { if ("status" in status) return status.status @@ -577,6 +578,73 @@ it.instance( }, ) +it.instance( + "prompt list change notifications refresh cached commands", + () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + const command = yield* Command.Service + lastCreatedClientName = "prompt-server" + const serverState = getOrCreateClientState("prompt-server") + serverState.capabilities = { prompts: { listChanged: true } } + serverState.prompts = [{ name: "first" }] + + yield* mcp.add("prompt-server", { + type: "local", + command: ["echo", "test"], + }) + + expect((yield* command.list()).some((item) => item.name === "prompt-server:first")).toBe(true) + serverState.prompts = [{ name: "second" }] + + const handler = Array.from(serverState.notificationHandlers.values())[0] + expect(handler).toBeDefined() + yield* Effect.promise(() => handler?.()) + + const commands = yield* command.list() + expect(commands.some((item) => item.name === "prompt-server:second")).toBe(true) + expect(commands.some((item) => item.name === "prompt-server:first")).toBe(false) + }), + { config: { mcp: {} } }, +) + +it.instance( + "prompt notifications from replaced clients do not refresh commands", + () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + const command = yield* Command.Service + lastCreatedClientName = "prompt-server" + const firstState = getOrCreateClientState("prompt-server") + firstState.capabilities = { prompts: { listChanged: true } } + firstState.prompts = [{ name: "first" }] + + yield* mcp.add("prompt-server", { + type: "local", + command: ["echo", "test"], + }) + expect((yield* command.list()).some((item) => item.name === "prompt-server:first")).toBe(true) + const staleHandler = Array.from(firstState.notificationHandlers.values())[0] + + clientStates.delete("prompt-server") + const secondState = getOrCreateClientState("prompt-server") + secondState.capabilities = { prompts: { listChanged: true } } + secondState.prompts = [{ name: "second" }] + yield* mcp.add("prompt-server", { + type: "local", + command: ["echo", "test"], + }) + + yield* Effect.promise(() => staleHandler?.()) + expect((yield* command.list()).some((item) => item.name === "prompt-server:second")).toBe(false) + + const activeHandler = Array.from(secondState.notificationHandlers.values())[0] + yield* Effect.promise(() => activeHandler?.()) + expect((yield* command.list()).some((item) => item.name === "prompt-server:second")).toBe(true) + }), + { config: { mcp: {} } }, +) + it.instance( "resources() returns resources from connected servers", () =>