Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 29 additions & 12 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ListToolsResultSchema,
ToolSchema,
type Tool as MCPToolDef,
ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "@/config/config"
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 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))
})
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

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>(
Expand Down
62 changes: 62 additions & 0 deletions packages/opencode/test/mcp/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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 ---
Expand Down Expand Up @@ -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",
() =>
Expand Down
16 changes: 16 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export type Event =
| EventTuiToastShow2
| EventTuiSessionSelect2
| EventMcpToolsChanged
| EventMcpResourcesChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventProjectDirectoriesUpdated
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions packages/tui/src/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Loading