diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index f884af2bf23..9c75d706ca2 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -134,6 +134,25 @@ describe("applyGlobalEvent", () => { }) describe("applyDirectoryEvent", () => { + test("refreshes commands when command catalog changes", () => { + const [store, setStore] = createStore(baseState()) + let refreshCount = 0 + + applyDirectoryEvent({ + event: { type: "command.changed" }, + store, + setStore, + push() {}, + directory: "/tmp", + loadCommand() { + refreshCount += 1 + }, + loadLsp() {}, + }) + + expect(refreshCount).toBe(1) + }) + test("preserves a Home-specific retained session limit", () => { const [store, setStore] = createStore( baseState({ diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index f398aa995eb..1a96d197947 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -96,6 +96,7 @@ export function applyDirectoryEvent(input: { setStore: SetStoreFunction push: (directory: string) => void directory: string + loadCommand?: () => void loadLsp: () => void vcsCache?: VcsCache setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void @@ -108,6 +109,10 @@ export function applyDirectoryEvent(input: { input.push(input.directory) return } + case "command.changed": { + input.loadCommand?.() + return + } case "session.created": { const info = (event.properties as { info: Session }).info const result = Binary.search(input.store.session, info.id, (s) => s.id) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 105d40eecab..adc7218722e 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -407,6 +407,13 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { setSessionTodo, retainedLimit: sessionMeta.get(key)?.limit, vcsCache: children.vcsCache.get(key), + loadCommand: () => { + void retry(() => + sdkFor(directory) + .command.list() + .then((x) => setStore("command", x.data ?? [])), + ) + }, loadLsp: () => { void queryClient.fetchQuery(queryOptionsApi.lsp(key)) }, diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 0463e83f6be..d3c228f1e10 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -5,6 +5,7 @@ import type { InstanceContext } from "@/project/instance-context" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" import { Config } from "@/config/config" +import { EventV2Bridge } from "@/event-v2-bridge" import { MCP } from "../mcp" import { Skill } from "../skill" import { EventV2 } from "@opencode-ai/core/event" @@ -16,6 +17,10 @@ type State = { } export const Event = { + Changed: EventV2.define({ + type: "command.changed", + schema: {}, + }), Executed: EventV2.define({ type: "command.executed", schema: { @@ -69,8 +74,9 @@ 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 build = Effect.fn("Command.build")(function* (ctx: InstanceContext) { const cfg = yield* config.get() const bridge = yield* EffectBridge.make() const commands: Record = {} @@ -152,9 +158,27 @@ export const layer = Layer.effect( } } - return { - commands, + return commands + }) + + const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { + const s: State = { + commands: yield* build(ctx), } + + const unsubscribe = yield* events.listen((event) => { + if (event.type !== MCP.CatalogChanged.type) return Effect.void + if (event.location?.directory && event.location.directory !== ctx.directory) return Effect.void + const data = event.data as EventV2.Data + if (!data.kinds.includes("prompts")) return Effect.void + return Effect.gen(function* () { + s.commands = yield* build(ctx) + yield* events.publish(Event.Changed, {}, event.location ? { location: event.location } : undefined) + }) + }) + yield* Effect.addFinalizer(() => unsubscribe) + + return s }) const state = yield* InstanceState.make((ctx) => init(ctx)) @@ -174,11 +198,12 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(MCP.defaultLayer), Layer.provide(Skill.defaultLayer), ) -export const node = LayerNode.make(layer, [Config.node, MCP.node, Skill.node]) +export const node = LayerNode.make(layer, [Config.node, EventV2Bridge.node, MCP.node, Skill.node]) export * as Command from "." diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 36e0a445586..fd4067933ab 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -11,6 +11,7 @@ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { type LoggingMessageNotification, LoggingMessageNotificationSchema, + PromptListChangedNotificationSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" @@ -52,6 +53,14 @@ export const ToolsChanged = EventV2.define({ }, }) +export const CatalogChanged = EventV2.define({ + type: "mcp.catalog.changed", + schema: { + server: Schema.String, + kinds: Schema.Array(Schema.Literals(["prompts", "tools"])), + }, +}) + export const BrowserOpenFailed = EventV2.define({ type: "mcp.browser.open.failed", schema: { @@ -406,6 +415,7 @@ export const layer = Layer.effect( bridge.fork( Effect.logWarning("MCP connection closed", { server: name }).pipe( Effect.andThen(events.publish(ToolsChanged, { server: name })), + Effect.andThen(events.publish(CatalogChanged, { server: name, kinds: ["prompts"] })), Effect.ignore, ), ) @@ -415,16 +425,23 @@ 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 + if (client.getServerCapabilities()?.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)) + }) + } + + if (!client.getServerCapabilities()?.prompts) return + client.setNotificationHandler(PromptListChangedNotificationSchema, async () => { + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + await bridge.promise(events.publish(CatalogChanged, { server: name, kinds: ["prompts"] }).pipe(Effect.ignore)) }) } @@ -518,7 +535,10 @@ export const layer = Layer.effect( delete s.clients[name] delete s.defs[name] if (!client) return Effect.void - return Effect.tryPromise(() => client.close()).pipe(Effect.ignore) + return Effect.tryPromise(() => client.close()).pipe( + Effect.ignore, + Effect.andThen(events.publish(CatalogChanged, { server: name, kinds: ["prompts"] }).pipe(Effect.ignore)), + ) } const storeClient = Effect.fnUntraced(function* ( @@ -535,6 +555,9 @@ export const layer = Layer.effect( s.defs[name] = listed watch(s, name, client, bridge, timeout) if (previous) yield* Effect.tryPromise(() => previous.close()).pipe(Effect.ignore) + if (client.getServerCapabilities()?.prompts) { + yield* events.publish(CatalogChanged, { server: name, kinds: ["prompts"] }).pipe(Effect.ignore) + } return s.status[name] }) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 763f6a3197e..090882dd954 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,8 +1,9 @@ import path from "node:path" import { expect, mock, beforeEach } from "bun:test" -import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" +import { PromptListChangedNotificationSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" 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" import { TestInstance } from "../fixture/fixture" @@ -427,6 +428,36 @@ it.instance( { config: { mcp: {} } }, ) +it.instance( + "prompt change notifications publish catalog invalidation", + () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + lastCreatedClientName = "prompt-server" + const serverState = getOrCreateClientState("prompt-server") + + yield* mcp.add("prompt-server", { + type: "local", + command: ["echo", "test"], + }) + + let changed = 0 + const listener = (event: GlobalEvent) => { + if (event.payload.type !== MCP.CatalogChanged.type) return + changed += 1 + } + GlobalBus.on("event", listener) + + const handler = serverState.notificationHandlers.get(PromptListChangedNotificationSchema) + expect(handler).toBeDefined() + yield* Effect.promise(() => handler?.()) + GlobalBus.off("event", listener) + + expect(changed).toBe(1) + }), + { config: { mcp: {} } }, +) + // ======================================================================== // Test: connect() / disconnect() lifecycle // ======================================================================== diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 334f5d469cf..982fce7bba7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -74,7 +74,9 @@ export type Event = | EventTuiToastShow2 | EventTuiSessionSelect2 | EventMcpToolsChanged + | EventMcpCatalogChanged | EventMcpBrowserOpenFailed + | EventCommandChanged | EventCommandExecuted | EventProjectUpdated | EventSessionStatus @@ -1468,6 +1470,14 @@ export type GlobalEvent = { server: string } } + | { + id: string + type: "mcp.catalog.changed" + properties: { + server: string + kinds: Array<"prompts" | "tools"> + } + } | { id: string type: "mcp.browser.open.failed" @@ -1476,6 +1486,13 @@ export type GlobalEvent = { url: string } } + | { + id: string + type: "command.changed" + properties: { + [key: string]: unknown + } + } | { id: string type: "command.executed" @@ -5075,6 +5092,15 @@ export type EventMcpToolsChanged = { } } +export type EventMcpCatalogChanged = { + id: string + type: "mcp.catalog.changed" + properties: { + server: string + kinds: Array<"prompts" | "tools"> + } +} + export type EventMcpBrowserOpenFailed = { id: string type: "mcp.browser.open.failed" @@ -5084,6 +5110,14 @@ export type EventMcpBrowserOpenFailed = { } } +export type EventCommandChanged = { + id: string + type: "command.changed" + properties: { + [key: string]: unknown + } +} + export type EventCommandExecuted = { id: string type: "command.executed" diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 55e1d750ae5..b8bac90bc27 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -77,6 +77,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: sdk.directory ?? process.cwd(), }) + async function refreshCommands(ref?: LocationRef) { + const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true }) + const key = locationKey(result.data.location) + setStore("location", key, "command", result.data.data) + } + const message = { update(sessionID: string, fn: (messages: SessionMessage[]) => void) { setStore( @@ -133,6 +139,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break + case "command.changed": { + void refreshCommands({ directory: metadata.directory, workspaceID: metadata.workspace }) + break + } case "session.next.model.switched": message.update(event.properties.sessionID, (draft) => { message.prepend(draft, { @@ -503,9 +513,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.command }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, "command", result.data.data) + await refreshCommands(ref) }, }, integration: { diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 4882c13920f..4c22a51d3a6 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -164,6 +164,9 @@ export const { case "server.instance.disposed": void bootstrap() break + case "command.changed": + void sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))) + break case "permission.replied": { const requests = store.permission[event.properties.sessionID] if (!requests) break