fix(mcp): harden prompt catalog refresh

This commit is contained in:
Aiden Cline 2026-06-13 11:31:33 -05:00
parent f77f037225
commit 3bfc69cf7e
5 changed files with 56 additions and 7 deletions

View file

@ -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))

View file

@ -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))

View file

@ -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<string, MCPNS.Status> | 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",
() =>

View file

@ -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":

View file

@ -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]