From 5917dc7b49131b8d30ad03dbf3ac9dc4ea95b6cf Mon Sep 17 00:00:00 2001 From: Kevin O'Connell <17347501+kevoconnell@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:43:03 -0700 Subject: [PATCH] Log why an MCP server was unavailable, not just that it was connectRemote and connectLocal both capture the failure reason into status.error, but the log site only emitted status.status, so a connect failure reached Loki as `status=failed` with no exception attached. That is the common failure path: over 15 days of Replo production logs it accounted for 109 of 111 occurrences, against 2 for the tools/list path that does log its error. Losing the reason on the frequent one made the whole class undiagnosable. Status is a discriminated union and needs_auth carries no error, so the `in` check narrows to the two variants that do. Co-Authored-By: Claude Opus 5 --- packages/opencode/src/mcp/index.ts | 7 +++++- packages/opencode/test/mcp/lifecycle.test.ts | 26 +++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 9a750d0b2600..3e08ec5954a9 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -399,7 +399,12 @@ const layer = Layer.effect( if (!mcpClient) { if (status.status !== "connected" && status.status !== "disabled") { - yield* Effect.logWarning("server unavailable", { key, type: mcp.type, status: status.status }) + yield* Effect.logWarning("server unavailable", { + key, + type: mcp.type, + status: status.status, + error: "error" in status ? status.error : undefined, + }) } return { status } satisfies CreateResult } diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 7a3048716aa9..d74edd0220b1 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -14,7 +14,7 @@ import { type Tool, } from "@modelcontextprotocol/sdk/types.js" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Cause, Effect, Exit } from "effect" +import { Cause, Effect, Exit, Logger } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { MCP } from "../../src/mcp/index" import { McpOAuthCallback } from "../../src/mcp/oauth-callback" @@ -493,6 +493,30 @@ it.instance("unavailable remote server is marked failed without tools", () => }), ) +it.instance("unavailable remote server surfaces the underlying error in the log", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.sync(() => Bun.serve({ port: 0, fetch: () => new Response("unavailable", { status: 503 }) })), + (http) => Effect.promise(() => http.stop(true)), + ) + const mcp = yield* MCP.Service + const captured: Array = [] + const capture = Logger.make((options) => { + captured.push(options.message) + }) + + yield* mcp + .add("unlogged", remote(server.url.toString(), 500)) + .pipe(Effect.provide(Logger.layer([capture], { mergeWithExisting: true }))) + + const logged = captured.find((item) => Array.isArray(item) && item[0] === "server unavailable") + expect(logged).toBeDefined() + if (!Array.isArray(logged)) return + const fields: unknown = logged[1] + expect(fields).toMatchObject({ status: "failed", error: expect.any(String) }) + }), +) + it.instance("tools() prefixes sanitized server and tool names", () => Effect.gen(function* () { const server = yield* lifecycleServer({ capabilities: { tools: {} } })