fix(mcp): refresh prompt slash commands

This commit is contained in:
OpenCode
2026-06-13 01:17:26 +00:00
parent dbbe67f066
commit 594eddb288
9 changed files with 172 additions and 17 deletions
@@ -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({
@@ -96,6 +96,7 @@ export function applyDirectoryEvent(input: {
setStore: SetStoreFunction<State>
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)
+7
View File
@@ -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))
},
+29 -4
View File
@@ -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<string, Info> = {}
@@ -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<typeof MCP.CatalogChanged>
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<State>((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 "."
+32 -9
View File
@@ -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]
})
+32 -1
View File
@@ -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
// ========================================================================
+34
View File
@@ -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"
+11 -3
View File
@@ -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: {
+3
View File
@@ -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