Skip to content

Commit b5cb9aa

Browse files
authored
fix(opencode): respect MCP server capabilities (#31271)
1 parent 4d09a71 commit b5cb9aa

4 files changed

Lines changed: 151 additions & 5 deletions

File tree

packages/opencode/src/mcp/index.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ function fetchFromClient<T extends { name: string }>(
204204
return Effect.tryPromise({
205205
try: () => listFn(client),
206206
catch: (e: any) => {
207-
log.error(`failed to get ${label}`, { clientName, error: e.message })
207+
log.warn(`failed to get ${label}`, { clientName, error: e.message })
208208
return e
209209
},
210210
}).pipe(
@@ -472,7 +472,7 @@ export const layer = Layer.effect(
472472
return { status } satisfies CreateResult
473473
}
474474

475-
const listed = yield* defs(key, mcpClient, mcp.timeout)
475+
const listed = mcpClient.getServerCapabilities()?.tools ? yield* defs(key, mcpClient, mcp.timeout) : []
476476
if (!listed) {
477477
yield* Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore)
478478
return { status: { status: "failed", error: "Failed to get tools" } } satisfies CreateResult
@@ -508,6 +508,7 @@ export const layer = Layer.effect(
508508
)
509509

510510
function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) {
511+
if (!client.getServerCapabilities()?.tools) return
511512
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
512513
log.info("tools list changed notification received", { server: name })
513514
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
@@ -718,12 +719,21 @@ export const layer = Layer.effect(
718719

719720
const prompts = Effect.fn("MCP.prompts")(function* () {
720721
const s = yield* InstanceState.get(state)
721-
return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts")
722+
return yield* collectFromConnected(
723+
s,
724+
(c) => (c.getServerCapabilities()?.prompts ? c.listPrompts().then((r) => r.prompts) : Promise.resolve([])),
725+
"prompts",
726+
)
722727
})
723728

724729
const resources = Effect.fn("MCP.resources")(function* () {
725730
const s = yield* InstanceState.get(state)
726-
return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources")
731+
return yield* collectFromConnected(
732+
s,
733+
(c) =>
734+
c.getServerCapabilities()?.resources ? c.listResources().then((r) => r.resources) : Promise.resolve([]),
735+
"resources",
736+
)
727737
})
728738

729739
const withClient = Effect.fnUntraced(function* <A>(
@@ -848,7 +858,11 @@ export const layer = Layer.effect(
848858
Effect.tapError(() => Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)),
849859
)
850860

851-
const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined
861+
const listed = client
862+
? client.getServerCapabilities()?.tools
863+
? yield* defs(mcpName, client, mcpConfig.timeout)
864+
: []
865+
: undefined
852866
if (!client || !listed) {
853867
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
854868
return { status: "failed", error: "Failed to get tools" } as Status

packages/opencode/test/mcp/lifecycle.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import { testEffect } from "../lib/effect"
77

88
// Per-client state for controlling mock behavior
99
interface MockClientState {
10+
capabilities: { tools?: object; prompts?: object; resources?: object }
1011
tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
1112
listToolsCalls: number
13+
listPromptsCalls: number
14+
listResourcesCalls: number
1215
requestCalls: number
1316
listToolsShouldFail: boolean
1417
listToolsError: string
@@ -35,8 +38,11 @@ function getOrCreateClientState(name?: string): MockClientState {
3538
let state = clientStates.get(key)
3639
if (!state) {
3740
state = {
41+
capabilities: { tools: {}, prompts: {}, resources: {} },
3842
tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }],
3943
listToolsCalls: 0,
44+
listPromptsCalls: 0,
45+
listResourcesCalls: 0,
4046
requestCalls: 0,
4147
listToolsShouldFail: false,
4248
listToolsError: "listTools failed",
@@ -133,6 +139,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
133139
this._state?.notificationHandlers.set(schema, handler)
134140
}
135141

142+
getServerCapabilities() {
143+
return this._state?.capabilities
144+
}
145+
136146
async listTools() {
137147
if (this._state) this._state.listToolsCalls++
138148
if (this._state?.listToolsShouldFail) {
@@ -148,13 +158,15 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
148158
}
149159

150160
async listPrompts() {
161+
if (this._state) this._state.listPromptsCalls++
151162
if (this._state?.listPromptsShouldFail) {
152163
throw new Error("listPrompts failed")
153164
}
154165
return { prompts: this._state?.prompts ?? [] }
155166
}
156167

157168
async listResources() {
169+
if (this._state) this._state.listResourcesCalls++
158170
if (this._state?.listResourcesShouldFail) {
159171
throw new Error("listResources failed")
160172
}
@@ -598,6 +610,84 @@ it.instance(
598610
},
599611
)
600612

613+
it.instance(
614+
"resource-only servers connect without listing tools",
615+
() =>
616+
MCP.Service.use((mcp: MCPNS.Interface) =>
617+
Effect.gen(function* () {
618+
lastCreatedClientName = "resource-only-server"
619+
const serverState = getOrCreateClientState("resource-only-server")
620+
serverState.capabilities = { resources: {} }
621+
serverState.resources = [{ name: "docs", uri: "docs://readme" }]
622+
623+
const result = yield* mcp.add("resource-only-server", {
624+
type: "local",
625+
command: ["echo", "test"],
626+
})
627+
628+
expect(statusName(result.status, "resource-only-server")).toBe("connected")
629+
expect(serverState.listToolsCalls).toBe(0)
630+
expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
631+
expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs"])
632+
expect(serverState.listResourcesCalls).toBe(1)
633+
expect(serverState.listPromptsCalls).toBe(0)
634+
}),
635+
),
636+
{ config: { mcp: {} } },
637+
)
638+
639+
it.instance(
640+
"prompt-only servers connect without listing tools",
641+
() =>
642+
MCP.Service.use((mcp: MCPNS.Interface) =>
643+
Effect.gen(function* () {
644+
lastCreatedClientName = "prompt-only-server"
645+
const serverState = getOrCreateClientState("prompt-only-server")
646+
serverState.capabilities = { prompts: {} }
647+
serverState.prompts = [{ name: "review" }]
648+
649+
const result = yield* mcp.add("prompt-only-server", {
650+
type: "local",
651+
command: ["echo", "test"],
652+
})
653+
654+
expect(statusName(result.status, "prompt-only-server")).toBe("connected")
655+
expect(serverState.listToolsCalls).toBe(0)
656+
expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
657+
expect(Object.keys(yield* mcp.prompts())).toEqual(["prompt-only-server:review"])
658+
expect(serverState.listPromptsCalls).toBe(1)
659+
expect(serverState.listResourcesCalls).toBe(0)
660+
}),
661+
),
662+
{ config: { mcp: {} } },
663+
)
664+
665+
it.instance(
666+
"tools-only servers skip optional prompt and resource discovery",
667+
() =>
668+
MCP.Service.use((mcp: MCPNS.Interface) =>
669+
Effect.gen(function* () {
670+
lastCreatedClientName = "tools-only-server"
671+
const serverState = getOrCreateClientState("tools-only-server")
672+
serverState.capabilities = { tools: {} }
673+
674+
const result = yield* mcp.add("tools-only-server", {
675+
type: "local",
676+
command: ["echo", "test"],
677+
})
678+
679+
expect(statusName(result.status, "tools-only-server")).toBe("connected")
680+
expect(serverState.listToolsCalls).toBe(1)
681+
expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only-server_test_tool"])
682+
expect(yield* mcp.prompts()).toEqual({})
683+
expect(yield* mcp.resources()).toEqual({})
684+
expect(serverState.listPromptsCalls).toBe(0)
685+
expect(serverState.listResourcesCalls).toBe(0)
686+
}),
687+
),
688+
{ config: { mcp: {} } },
689+
)
690+
601691
it.instance(
602692
"prompts() skips disconnected servers",
603693
() =>

packages/opencode/test/mcp/oauth-auto-connect.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const transportCalls: Array<{
2121
// auth flow (which calls provider.state()) or a simple UnauthorizedError.
2222
let simulateAuthFlow = true
2323
let connectSucceedsImmediately = false
24+
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
25+
let listToolsCalls = 0
2426

2527
// Mock the transport constructors to simulate OAuth auto-auth on 401
2628
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
@@ -91,10 +93,19 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
9193

9294
setNotificationHandler() {}
9395

96+
getServerCapabilities() {
97+
return serverCapabilities
98+
}
99+
94100
async listTools() {
101+
listToolsCalls++
95102
return { tools: [{ name: "test_tool", inputSchema: { type: "object", properties: {} } }] }
96103
}
97104

105+
async listResources() {
106+
return { resources: [{ name: "docs", uri: "docs://readme" }] }
107+
}
108+
98109
async close() {}
99110
},
100111
}))
@@ -108,6 +119,8 @@ beforeEach(() => {
108119
transportCalls.length = 0
109120
simulateAuthFlow = true
110121
connectSucceedsImmediately = false
122+
serverCapabilities = { tools: {} }
123+
listToolsCalls = 0
111124
})
112125

113126
// Import modules after mocking
@@ -234,3 +247,28 @@ mcpTest.instance(
234247
),
235248
{ config: config("test-oauth-connect") },
236249
)
250+
251+
mcpTest.instance(
252+
"authenticate() connects a resource-only server without listing tools",
253+
() =>
254+
MCP.Service.use((mcp) =>
255+
Effect.gen(function* () {
256+
const added = yield* mcp.add("test-oauth-resources", {
257+
type: "remote",
258+
url: "https://example.com/mcp",
259+
})
260+
const before = added.status as Record<string, { status: string }>
261+
expect(before["test-oauth-resources"]?.status).toBe("needs_auth")
262+
263+
simulateAuthFlow = false
264+
connectSucceedsImmediately = true
265+
serverCapabilities = { resources: {} }
266+
267+
const result = yield* mcp.authenticate("test-oauth-resources")
268+
expect(result.status).toBe("connected")
269+
expect(listToolsCalls).toBe(0)
270+
expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs"])
271+
}),
272+
),
273+
{ config: config("test-oauth-resources") },
274+
)

packages/opencode/test/mcp/oauth-browser.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
8989
async connect(transport: { start: () => Promise<void> }) {
9090
await transport.start()
9191
}
92+
93+
getServerCapabilities() {
94+
return { tools: {} }
95+
}
9296
},
9397
}))
9498

0 commit comments

Comments
 (0)