fix(mcp): refresh commands when prompt list changes

This commit is contained in:
Aiden Cline
2026-06-08 12:34:04 -05:00
parent b34d9242d1
commit b880d7d5a1
3 changed files with 105 additions and 12 deletions
+8
View File
@@ -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<State>((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 "."
+27 -10
View File
@@ -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 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
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))
})
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<State>(
+70 -2
View File
@@ -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<string, MCPNS.Status> | 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",
() =>