Compare commits

...

3 Commits

Author SHA1 Message Date
Aiden Cline 3bfc69cf7e fix(mcp): harden prompt catalog refresh 2026-06-13 11:31:33 -05:00
OpenCode f77f037225 fix(mcp): invalidate replaced prompt catalogs 2026-06-13 01:37:24 +00:00
OpenCode 594eddb288 fix(mcp): refresh prompt slash commands 2026-06-13 01:17:26 +00:00
9 changed files with 258 additions and 20 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)
+13
View File
@@ -407,6 +407,19 @@ 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 ?? [])),
).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))
},
+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?.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))
})
}
@@ -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 (previous || client.getServerCapabilities()?.prompts) {
yield* events.publish(CatalogChanged, { server: name, kinds: ["prompts"] }).pipe(Effect.ignore)
}
return s.status[name]
})
+109 -4
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 { Cause, Effect, Exit } from "effect"
import { PromptListChangedNotificationSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
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"
import { TestInstance } from "../fixture/fixture"
@@ -10,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
@@ -58,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,
@@ -242,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
@@ -427,6 +430,108 @@ 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: {} } },
)
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",
() =>
Effect.gen(function* () {
const mcp = yield* MCP.Service
lastCreatedClientName = "prompt-replace-server"
const serverState = getOrCreateClientState("prompt-replace-server")
serverState.prompts = [{ name: "original" }]
yield* mcp.add("prompt-replace-server", {
type: "local",
command: ["echo", "test"],
})
serverState.capabilities = { tools: {} }
let changed = 0
const listener = (event: GlobalEvent) => {
if (event.payload.type !== MCP.CatalogChanged.type) return
changed += 1
}
GlobalBus.on("event", listener)
yield* mcp.add("prompt-replace-server", {
type: "local",
command: ["echo", "test"],
})
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 }).catch(() => {})
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: {
+6
View File
@@ -164,6 +164,12 @@ 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 ?? [])))
.catch(() => {})
break
case "permission.replied": {
const requests = store.permission[event.properties.sessionID]
if (!requests) break