Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline c03c2be483 feat(mcp): support resource list change notifications 2026-06-08 12:32:47 -05:00
4 changed files with 112 additions and 10 deletions
+27 -10
View File
@@ -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",
() =>
+16
View File
@@ -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"
+7
View File
@@ -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 })