mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 03:09:48 -04:00
fix(mcp): refresh commands when prompt list changes
This commit is contained in:
@@ -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 "."
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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",
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user