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
56 changes: 41 additions & 15 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ToolSchema,
type Tool as MCPToolDef,
ToolListChangedNotificationSchema,
ResourceUpdatedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "@/config/config"
import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp"
Expand Down Expand Up @@ -56,6 +57,14 @@ export const ToolsChanged = EventV2.define({
},
})

export const ResourceUpdated = EventV2.define({
type: "mcp.resource.updated",
schema: {
server: Schema.String,
uri: Schema.String,
},
})

export const BrowserOpenFailed = EventV2.define({
type: "mcp.browser.open.failed",
schema: {
Expand Down Expand Up @@ -508,18 +517,29 @@ 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?.subscribe) {
client.setNotificationHandler(ResourceUpdatedNotificationSchema, async (notification) => {
log.info("resource updated notification received", { server: name, uri: notification.params.uri })
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
await bridge.promise(
events.publish(ResourceUpdated, { server: name, uri: notification.params.uri }).pipe(Effect.ignore),
)
})
}
}

const state = yield* InstanceState.make<State>(
Expand Down Expand Up @@ -768,9 +788,15 @@ export const layer = Layer.effect(
})

const readResource = Effect.fn("MCP.readResource")(function* (clientName: string, resourceUri: string) {
return yield* withClient(clientName, (client) => client.readResource({ uri: resourceUri }), "readResource", {
resourceUri,
})
return yield* withClient(
clientName,
async (client) => {
if (client.getServerCapabilities()?.resources?.subscribe) await client.subscribeResource({ uri: resourceUri })
return client.readResource({ uri: resourceUri })
},
"readResource",
{ resourceUri },
)
})

const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
Expand Down
106 changes: 105 additions & 1 deletion packages/opencode/test/mcp/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { expect, mock, beforeEach } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import { Cause, Deferred, Effect, Exit } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
import { testEffect } from "../lib/effect"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"

// --- Mock infrastructure ---

Expand All @@ -12,6 +13,8 @@ interface MockClientState {
listToolsCalls: number
listPromptsCalls: number
listResourcesCalls: number
readResourceCalls: number
subscribedResources: string[]
requestCalls: number
listToolsShouldFail: boolean
listToolsError: string
Expand Down Expand Up @@ -43,6 +46,8 @@ function getOrCreateClientState(name?: string): MockClientState {
listToolsCalls: 0,
listPromptsCalls: 0,
listResourcesCalls: 0,
readResourceCalls: 0,
subscribedResources: [],
requestCalls: 0,
listToolsShouldFail: false,
listToolsError: "listTools failed",
Expand Down Expand Up @@ -173,6 +178,16 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
return { resources: this._state?.resources ?? [] }
}

async subscribeResource(input: { uri: string }) {
this._state?.subscribedResources.push(input.uri)
return {}
}

async readResource(input: { uri: string }) {
if (this._state) this._state.readResourceCalls++
return { contents: [{ uri: input.uri, text: "resource contents" }] }
}

async close() {
if (this._state) this._state.closed = true
}
Expand All @@ -192,6 +207,7 @@ beforeEach(() => {
// Import after mocks
const { MCP } = await import("../../src/mcp/index")
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
const { ResourceUpdatedNotificationSchema } = await import("@modelcontextprotocol/sdk/types.js")

const it = testEffect(MCP.defaultLayer)

Expand Down Expand Up @@ -610,6 +626,94 @@ it.instance(
},
)

it.instance(
"readResource() subscribes when the server supports resource updates",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
lastCreatedClientName = "resource-server"
const serverState = getOrCreateClientState("resource-server")
serverState.capabilities = { resources: { subscribe: true } }

yield* mcp.add("resource-server", {
type: "local",
command: ["echo", "test"],
})

const resource = yield* mcp.readResource("resource-server", "docs://readme")
expect(resource?.contents).toEqual([{ uri: "docs://readme", text: "resource contents" }])
expect(serverState.subscribedResources).toEqual(["docs://readme"])
expect(serverState.readResourceCalls).toBe(1)
}),
),
{ config: { mcp: {} } },
)

it.instance(
"resource updates require advertised subscription 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"],
})
yield* mcp.readResource("resource-server", "docs://readme")

expect(serverState.notificationHandlers.has(ResourceUpdatedNotificationSchema)).toBe(false)
expect(serverState.subscribedResources).toEqual([])
expect(serverState.readResourceCalls).toBe(1)
}),
),
{ config: { mcp: {} } },
)

it.instance(
"resource update notifications publish server and URI for the current client only",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
const event = yield* Deferred.make<GlobalEvent>()
const listener = (value: GlobalEvent) => {
if (value.payload.type === MCP.ResourceUpdated.type) Deferred.doneUnsafe(event, Effect.succeed(value))
}
GlobalBus.on("event", listener)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener)))

lastCreatedClientName = "resource-server-old"
const oldState = getOrCreateClientState("resource-server-old")
oldState.capabilities = { resources: { subscribe: true } }
yield* mcp.add("resource-server", { type: "local", command: ["echo", "test"] })
const oldHandler = oldState.notificationHandlers.get(ResourceUpdatedNotificationSchema)

lastCreatedClientName = "resource-server-new"
const newState = getOrCreateClientState("resource-server-new")
newState.capabilities = { resources: { subscribe: true } }
yield* mcp.add("resource-server", { type: "local", command: ["echo", "test"] })
const newHandler = newState.notificationHandlers.get(ResourceUpdatedNotificationSchema)

expect(oldHandler).toBeDefined()
expect(newHandler).toBeDefined()
yield* Effect.promise(() =>
oldHandler!({ method: "notifications/resources/updated", params: { uri: "docs://stale" } }),
)
yield* Effect.promise(() =>
newHandler!({ method: "notifications/resources/updated", params: { uri: "docs://current" } }),
)

expect((yield* Deferred.await(event)).payload).toMatchObject({
type: "mcp.resource.updated",
properties: { server: "resource-server", uri: "docs://current" },
})
}),
),
{ config: { mcp: {} } },
)

it.instance(
"resource-only servers connect without listing tools",
() =>
Expand Down
18 changes: 18 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
| EventMcpResourceUpdated
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventProjectDirectoriesUpdated
Expand Down Expand Up @@ -1463,6 +1464,14 @@ export type GlobalEvent = {
server: string
}
}
| {
id: string
type: "mcp.resource.updated"
properties: {
server: string
uri: string
}
}
| {
id: string
type: "mcp.browser.open.failed"
Expand Down Expand Up @@ -5062,6 +5071,15 @@ export type EventMcpToolsChanged = {
}
}

export type EventMcpResourceUpdated = {
id: string
type: "mcp.resource.updated"
properties: {
server: string
uri: string
}
}

export type EventMcpBrowserOpenFailed = {
id: string
type: "mcp.browser.open.failed"
Expand Down
Loading