diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index adc7218722e..bc4cce26d32 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -412,7 +412,13 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { sdkFor(directory) .command.list() .then((x) => setStore("command", x.data ?? [])), - ) + ).catch((err) => { + showToast({ + variant: "error", + title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), + description: formatServerError(err, language.t), + }) + }) }, loadLsp: () => { void queryClient.fetchQuery(queryOptionsApi.lsp(key)) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index d24c858e305..ec9b54650d0 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -438,7 +438,7 @@ export const layer = Layer.effect( }) } - if (!client.getServerCapabilities()?.prompts) return + if (!client.getServerCapabilities()?.prompts?.listChanged) 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)) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 8ec0fbfb5b8..887f0c0d65b 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,7 +1,7 @@ import path from "node:path" import { expect, mock, beforeEach } from "bun:test" import { PromptListChangedNotificationSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" -import { Cause, Effect, Exit } from "effect" +import { Cause, Effect, Exit, Layer } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { testEffect } from "../lib/effect" @@ -11,7 +11,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?: { listChanged?: boolean }; resources?: object } capabilitiesShouldThrow: boolean tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> listToolsCalls: number @@ -59,7 +59,7 @@ function getOrCreateClientState(name?: string): MockClientState { let state = clientStates.get(key) if (!state) { state = { - capabilities: { tools: {}, prompts: {}, resources: {} }, + capabilities: { tools: {}, prompts: { listChanged: true }, resources: {} }, capabilitiesShouldThrow: false, tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }], listToolsCalls: 0, @@ -243,9 +243,11 @@ 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 commandIt = testEffect(Layer.mergeAll(Command.defaultLayer, MCP.defaultLayer)) function statusName(status: Record | MCPNS.Status, server: string) { if ("status" in status) return status.status @@ -458,6 +460,44 @@ it.instance( { config: { mcp: {} } }, ) +commandIt.instance( + "prompt change notifications rebuild slash commands", + () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + const command = yield* Command.Service + yield* command.list() + + lastCreatedClientName = "prompt-command-server" + const serverState = getOrCreateClientState("prompt-command-server") + serverState.prompts = [{ name: "original" }] + yield* mcp.add("prompt-command-server", { + type: "local", + command: ["echo", "test"], + }) + + expect((yield* command.get("prompt-command-server:original"))?.source).toBe("mcp") + serverState.prompts = [{ name: "replacement" }] + + let changed = 0 + const listener = (event: GlobalEvent) => { + if (event.payload.type !== Command.Event.Changed.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(yield* command.get("prompt-command-server:original")).toBeUndefined() + expect((yield* command.get("prompt-command-server:replacement"))?.source).toBe("mcp") + expect(changed).toBe(1) + }), + { config: { mcp: {} } }, +) + it.instance( "replacing a prompt-capable server publishes catalog invalidation", () => diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index b8bac90bc27..eb558d62a60 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -140,7 +140,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "command.changed": { - void refreshCommands({ directory: metadata.directory, workspaceID: metadata.workspace }) + void refreshCommands({ directory: metadata.directory, workspaceID: metadata.workspace }).catch(() => {}) break } case "session.next.model.switched": diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 4c22a51d3a6..8a0fcdc905d 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -165,7 +165,10 @@ export const { void bootstrap() break case "command.changed": - void sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))) + void sdk.client.command + .list({ workspace }) + .then((x) => setStore("command", reconcile(x.data ?? []))) + .catch(() => {}) break case "permission.replied": { const requests = store.permission[event.properties.sessionID]