Skip to content

Commit 19f6011

Browse files
rekram1-nodeBenGu3
authored andcommitted
fix(mcp): make client creation failure-safe (anomalyco#31595)
1 parent 7d1da05 commit 19f6011

2 files changed

Lines changed: 65 additions & 25 deletions

File tree

packages/opencode/src/mcp/index.ts

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
2727
import { EventV2 } from "@opencode-ai/core/event"
2828
import { TuiEvent } from "@/server/tui-event"
2929
import open from "open"
30-
import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
30+
import { Cause, Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
3131
import { EffectBridge } from "@/effect/bridge"
3232
import { InstanceState } from "@/effect/instance-state"
3333
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
@@ -446,31 +446,45 @@ export const layer = Layer.effect(
446446
)
447447
})
448448

449-
const create = Effect.fn("MCP.create")(function* (key: string, mcp: ConfigMCPV1.Info) {
450-
if (mcp.enabled === false) {
451-
return DISABLED_RESULT
452-
}
449+
const create = Effect.fn("MCP.create")(
450+
function* (key: string, mcp: ConfigMCPV1.Info) {
451+
if (mcp.enabled === false) {
452+
return DISABLED_RESULT
453+
}
453454

454-
const { client: mcpClient, status } =
455-
mcp.type === "remote"
456-
? yield* connectRemote(key, mcp as ConfigMCPV1.Info & { type: "remote" })
457-
: yield* connectLocal(key, mcp as ConfigMCPV1.Info & { type: "local" })
455+
const { client: mcpClient, status } =
456+
mcp.type === "remote"
457+
? yield* connectRemote(key, mcp as ConfigMCPV1.Info & { type: "remote" })
458+
: yield* connectLocal(key, mcp as ConfigMCPV1.Info & { type: "local" })
458459

459-
if (!mcpClient) {
460-
if (status.status !== "connected" && status.status !== "disabled") {
461-
yield* Effect.logWarning("server unavailable", { key, type: mcp.type, status: status.status })
460+
if (!mcpClient) {
461+
if (status.status !== "connected" && status.status !== "disabled") {
462+
yield* Effect.logWarning("server unavailable", { key, type: mcp.type, status: status.status })
463+
}
464+
return { status } satisfies CreateResult
462465
}
463-
return { status } satisfies CreateResult
464-
}
465466

466-
const listed = mcpClient.getServerCapabilities()?.tools ? yield* defs(mcpClient, mcp.timeout) : []
467-
if (!listed) {
468-
yield* Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore)
469-
return { status: { status: "failed", error: "Failed to get tools" } } satisfies CreateResult
470-
}
471-
472-
return { mcpClient, status, defs: listed } satisfies CreateResult
473-
})
467+
return yield* Effect.gen(function* () {
468+
const listed = mcpClient.getServerCapabilities()?.tools ? yield* defs(mcpClient, mcp.timeout) : []
469+
if (!listed) {
470+
return yield* Effect.fail(new Error("Failed to get tools"))
471+
}
472+
return { mcpClient, status, defs: listed } satisfies CreateResult
473+
}).pipe(
474+
Effect.catchCause((cause) =>
475+
Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore, Effect.andThen(Effect.failCause(cause))),
476+
),
477+
)
478+
},
479+
Effect.map((result): CreateResult => result),
480+
Effect.catchCause((cause) => {
481+
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
482+
const error = Cause.squash(cause)
483+
return Effect.succeed<CreateResult>({
484+
status: { status: "failed", error: error instanceof Error ? error.message : String(error) },
485+
})
486+
}),
487+
)
474488
const cfgSvc = yield* Config.Service
475489

476490
const descendants = Effect.fnUntraced(
@@ -537,9 +551,7 @@ export const layer = Layer.effect(
537551
return
538552
}
539553

540-
const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void))
541-
if (!result) return
542-
554+
const result = yield* create(key, mcp)
543555
s.status[key] = result.status
544556
if (result.mcpClient) {
545557
s.clients[key] = result.mcpClient

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { testEffect } from "../lib/effect"
88
// Per-client state for controlling mock behavior
99
interface MockClientState {
1010
capabilities: { tools?: object; prompts?: object; resources?: object }
11+
capabilitiesShouldThrow: boolean
1112
tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
1213
listToolsCalls: number
1314
listPromptsCalls: number
@@ -51,6 +52,7 @@ function getOrCreateClientState(name?: string): MockClientState {
5152
if (!state) {
5253
state = {
5354
capabilities: { tools: {}, prompts: {}, resources: {} },
55+
capabilitiesShouldThrow: false,
5456
tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }],
5557
listToolsCalls: 0,
5658
listPromptsCalls: 0,
@@ -155,6 +157,7 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
155157
}
156158

157159
getServerCapabilities() {
160+
if (this._state?.capabilitiesShouldThrow) throw new Error("capability discovery failed")
158161
return this._state?.capabilities
159162
}
160163

@@ -566,6 +569,31 @@ it.instance(
566569
},
567570
)
568571

572+
it.instance(
573+
"returns failed and closes the client when SDK initialization throws",
574+
() =>
575+
MCP.Service.use((mcp: MCPNS.Interface) =>
576+
Effect.gen(function* () {
577+
lastCreatedClientName = "defective-server"
578+
const serverState = getOrCreateClientState("defective-server")
579+
serverState.capabilitiesShouldThrow = true
580+
581+
const result = yield* mcp.add("defective-server", {
582+
type: "local",
583+
command: ["echo", "test"],
584+
})
585+
586+
expect(statusName(result.status, "defective-server")).toBe("failed")
587+
expect((yield* mcp.status())["defective-server"]).toEqual({
588+
status: "failed",
589+
error: "capability discovery failed",
590+
})
591+
expect(serverState.closed).toBe(true)
592+
}),
593+
),
594+
{ config: { mcp: {} } },
595+
)
596+
569597
it.instance(
570598
"falls back when MCP output schema refs fail SDK tool discovery",
571599
() =>

0 commit comments

Comments
 (0)