mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 04:01:10 -04:00
fix(mcp): refresh prompt slash commands
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
|
||||
@@ -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 "."
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
// ========================================================================
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user