mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 14:49:52 +00:00
feat(mcp): support resource list change notifications
This commit is contained in:
parent
b34d9242d1
commit
c03c2be483
4 changed files with 112 additions and 10 deletions
|
|
@ -11,6 +11,7 @@ import {
|
|||
ListToolsResultSchema,
|
||||
ToolSchema,
|
||||
type Tool as MCPToolDef,
|
||||
ResourceListChangedNotificationSchema,
|
||||
ToolListChangedNotificationSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Config } from "@/config/config"
|
||||
|
|
@ -56,6 +57,13 @@ export const ToolsChanged = EventV2.define({
|
|||
},
|
||||
})
|
||||
|
||||
export const ResourcesChanged = EventV2.define({
|
||||
type: "mcp.resources.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?.resources?.listChanged) {
|
||||
client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
|
||||
log.info("resources list changed notification received", { server: name })
|
||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||
await bridge.promise(events.publish(ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { expect, mock, beforeEach } from "bun:test"
|
||||
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"
|
||||
|
||||
// --- Mock infrastructure ---
|
||||
|
|
@ -610,6 +611,67 @@ it.instance(
|
|||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"resource list change notifications publish for the active client",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const received: string[] = []
|
||||
const listener = (event: GlobalEvent) => {
|
||||
if (event.payload.type === MCP.ResourcesChanged.type) received.push(event.payload.properties.server)
|
||||
}
|
||||
GlobalBus.on("event", listener)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener)))
|
||||
|
||||
lastCreatedClientName = "resource-server"
|
||||
const firstState = getOrCreateClientState("resource-server")
|
||||
firstState.capabilities = { resources: { listChanged: true } }
|
||||
yield* mcp.add("resource-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
expect(firstState.notificationHandlers.size).toBe(1)
|
||||
const staleHandler = Array.from(firstState.notificationHandlers.values())[0]
|
||||
|
||||
clientStates.delete("resource-server")
|
||||
const activeState = getOrCreateClientState("resource-server")
|
||||
activeState.capabilities = { resources: { listChanged: true } }
|
||||
yield* mcp.add("resource-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => staleHandler?.())
|
||||
expect(received).toEqual([])
|
||||
|
||||
const activeHandler = Array.from(activeState.notificationHandlers.values())[0]
|
||||
yield* Effect.promise(() => activeHandler?.())
|
||||
expect(received).toEqual(["resource-server"])
|
||||
}),
|
||||
{ config: { mcp: {} } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"resource list change notifications require advertised support",
|
||||
() =>
|
||||
MCP.Service.use((mcp: MCPNS.Interface) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "resource-server"
|
||||
const serverState = getOrCreateClientState("resource-server")
|
||||
serverState.capabilities = { resources: {} }
|
||||
|
||||
yield* mcp.add("resource-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
expect(serverState.notificationHandlers.size).toBe(0)
|
||||
}),
|
||||
),
|
||||
{ config: { mcp: {} } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"resource-only servers connect without listing tools",
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export type Event =
|
|||
| EventTuiToastShow2
|
||||
| EventTuiSessionSelect2
|
||||
| EventMcpToolsChanged
|
||||
| EventMcpResourcesChanged
|
||||
| EventMcpBrowserOpenFailed
|
||||
| EventCommandExecuted
|
||||
| EventProjectDirectoriesUpdated
|
||||
|
|
@ -1463,6 +1464,13 @@ export type GlobalEvent = {
|
|||
server: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "mcp.resources.changed"
|
||||
properties: {
|
||||
server: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "mcp.browser.open.failed"
|
||||
|
|
@ -5062,6 +5070,14 @@ export type EventMcpToolsChanged = {
|
|||
}
|
||||
}
|
||||
|
||||
export type EventMcpResourcesChanged = {
|
||||
id: string
|
||||
type: "mcp.resources.changed"
|
||||
properties: {
|
||||
server: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventMcpBrowserOpenFailed = {
|
||||
id: string
|
||||
type: "mcp.browser.open.failed"
|
||||
|
|
|
|||
|
|
@ -415,6 +415,13 @@ export const {
|
|||
break
|
||||
}
|
||||
|
||||
case "mcp.resources.changed": {
|
||||
void sdk.client.experimental.resource
|
||||
.list({ workspace })
|
||||
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {})))
|
||||
break
|
||||
}
|
||||
|
||||
case "vcs.branch.updated": {
|
||||
if (workspace === project.workspace.current()) {
|
||||
setStore("vcs", { branch: event.properties.branch })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue