Compare commits
1 commit
dev
...
feat/mcp-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c03c2be483 |
4 changed files with 114 additions and 12 deletions
|
|
@ -11,6 +11,7 @@ import {
|
||||||
ListToolsResultSchema,
|
ListToolsResultSchema,
|
||||||
ToolSchema,
|
ToolSchema,
|
||||||
type Tool as MCPToolDef,
|
type Tool as MCPToolDef,
|
||||||
|
ResourceListChangedNotificationSchema,
|
||||||
ToolListChangedNotificationSchema,
|
ToolListChangedNotificationSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js"
|
} from "@modelcontextprotocol/sdk/types.js"
|
||||||
import { Config } from "@/config/config"
|
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({
|
export const BrowserOpenFailed = EventV2.define({
|
||||||
type: "mcp.browser.open.failed",
|
type: "mcp.browser.open.failed",
|
||||||
schema: {
|
schema: {
|
||||||
|
|
@ -508,18 +516,27 @@ export const layer = Layer.effect(
|
||||||
)
|
)
|
||||||
|
|
||||||
function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) {
|
function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) {
|
||||||
if (!client.getServerCapabilities()?.tools) return
|
const capabilities = client.getServerCapabilities()
|
||||||
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
if (capabilities?.tools) {
|
||||||
log.info("tools list changed notification received", { server: name })
|
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
||||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
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))
|
const listed = await bridge.promise(defs(name, client, timeout))
|
||||||
if (!listed) return
|
if (!listed) return
|
||||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||||
|
|
||||||
s.defs[name] = listed
|
s.defs[name] = listed
|
||||||
await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
|
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>(
|
const state = yield* InstanceState.make<State>(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { expect, mock, beforeEach } from "bun:test"
|
import { expect, mock, beforeEach } from "bun:test"
|
||||||
import { Cause, Effect, Exit } from "effect"
|
import { Cause, Effect, Exit } from "effect"
|
||||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||||
|
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
// --- Mock infrastructure ---
|
// --- 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(
|
it.instance(
|
||||||
"resource-only servers connect without listing tools",
|
"resource-only servers connect without listing tools",
|
||||||
() =>
|
() =>
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export type Event =
|
||||||
| EventTuiToastShow2
|
| EventTuiToastShow2
|
||||||
| EventTuiSessionSelect2
|
| EventTuiSessionSelect2
|
||||||
| EventMcpToolsChanged
|
| EventMcpToolsChanged
|
||||||
|
| EventMcpResourcesChanged
|
||||||
| EventMcpBrowserOpenFailed
|
| EventMcpBrowserOpenFailed
|
||||||
| EventCommandExecuted
|
| EventCommandExecuted
|
||||||
| EventProjectDirectoriesUpdated
|
| EventProjectDirectoriesUpdated
|
||||||
|
|
@ -1463,6 +1464,13 @@ export type GlobalEvent = {
|
||||||
server: string
|
server: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
type: "mcp.resources.changed"
|
||||||
|
properties: {
|
||||||
|
server: string
|
||||||
|
}
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
id: string
|
id: string
|
||||||
type: "mcp.browser.open.failed"
|
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 = {
|
export type EventMcpBrowserOpenFailed = {
|
||||||
id: string
|
id: string
|
||||||
type: "mcp.browser.open.failed"
|
type: "mcp.browser.open.failed"
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,13 @@ export const {
|
||||||
break
|
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": {
|
case "vcs.branch.updated": {
|
||||||
if (workspace === project.workspace.current()) {
|
if (workspace === project.workspace.current()) {
|
||||||
setStore("vcs", { branch: event.properties.branch })
|
setStore("vcs", { branch: event.properties.branch })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue