Repository navigation
Description
Summary
When OpenCode's MCP client cannot obtain a server's tool list, the user-visible status collapses to the single string "Failed to get tools", and the error that actually caused it is discarded — not logged anywhere, not attached to the status. This makes an entire class of MCP failures undiagnosable from the API, the CLI, or logs.
Pinned commit: 05ea5073be967c779d326929b2de6228dda4159d (dev).
Where
packages/opencode/src/mcp/catalog.ts:38-40
export function defs(client: Client, timeout?: number) {
return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void))
}Effect.catch(() => Effect.void) turns any failure into undefined, with no log line.
packages/opencode/src/mcp/index.ts:390-394
const listed = mcpClient.getServerCapabilities()?.tools ? yield* McpCatalog.defs(mcpClient, mcp.timeout) : []
if (!listed) {
return yield* Effect.fail(new Error("Failed to get tools"))
}The thrown value is a fresh Error with a constant message — the original error is unreachable by then. index.ts:890 (authenticate) reports the same constant. The only thing GET /mcp can ever show is {"status":"failed","error":"Failed to get tools"}.
Two related details in the same area:
catalog.ts:145-160retries withTolerantListToolsResultSchemaonly whenisOutputSchemaValidationError(error)matches (catalog.ts:164-166, a regex over the message). Every other shape — transport closed, capability assertion, a server 500, a body that fails to parse — skips the retry and is then swallowed bydefs().if (!listed)can never fire for the "server advertises no tools" branch, because that branch yields[], and![]isfalse. So "server has no tools" is silently treated as success-with-zero-tools, while "tool list threw" is indistinguishable from "server unreachable at list time".
Why it matters (real integration, not hypothetical)
We run OpenCode as an on-device server on Android (Bun for Android, x86_64 emulator + arm64 hardware) with a loopback-only bind, and POST /mcp with {"type":"remote","url":"http://10.0.2.2:4551/mcp"} against a spec-legal StreamableHTTP MCP server (MCP SDK 1.29.0, both response modes) behaves like this:
fixture (the MCP server), same run:
[p5-mcp] streamable session initialized id=42921813-… <- OpenCode's session
[p5-mcp] POST /mcp -> 200 bytes=191 (initialize)
[p5-mcp] POST /mcp -> 202 (notifications/initialized)
...nothing else. No tools/list request ever reaches the server.
OpenCode's API:
POST /mcp p5-remote-http … -> 200 {"p5-remote-http":{"status":"failed","error":"Failed to get tools"}} [996ms]
Established by measurement, from the same device, same runtime, same route, same server:
- it is not a timeout: the registration returns in ~1 s, not at
DEFAULT_TIMEOUT = 30_000; - it is not response framing:
enableJsonResponse(singleapplication/jsonbody) fails with the identical string; - it is not the network or the server: a raw JSON-RPC client in the same process completes
initialize→notifications/initialized→tools/list(681 bytes of tool definitions) against that same listener; - the server-side handshake succeeded, so
connect()resolved; the throw happens insideclient.listTools()before a single request byte is written.
What we can say is exactly: upstream's client threw between a completed handshake and issuing tools/list. What we cannot say — because of the two snippets above — is what it threw. No OpenCode log line accompanies it (opencode-server.log for that run contains no entry for this MCP name at all), so from outside the project this is unreproducible-by-report: we can only say OpenCode returned a constant string.
Proposed fix (small, no behaviour change for healthy servers)
Preserve the cause. Either of:
// catalog.ts — keep the fallback semantics, log the reason
return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(
Effect.tapError((error) => Effect.logWarning("mcp tools/list failed", { error: String(error) })),
Effect.catch(() => Effect.void),
)
// index.ts — keep the message, add the cause
if (!listed) {
return yield* Effect.fail(new Error(`Failed to get tools (see server log for the underlying MCP error)`))
}Better still: store the underlying message in the Status ({status:"failed", error} already exists — put the real message there, and keep Failed to get tools as a prefix if some consumer depends on it). Same for index.ts:890. And since Status.error is what every UI, opencode mcp list, and third-party client shows, this is the difference between "our MCP server is broken, here's why" and a string that could mean five different things.
Acceptance
GET /mcp (or the CLI) for a server whose tools/list fails must contain text that distinguishes at least: transport not connected / closed, method not supported by the server, response failed schema validation, server error response, and timeout — and a matching log line must exist with the original error (ideally error.cause too).
Note
This is blocking a downstream integration gate for us (we must not modify OpenCode's server API from our side, so we can only document it as a platform restriction). Happy to test a patch on the Android/Bun-for-Android configuration and report whether the message becomes actionable. If an upstream fix lands, we drop our workaround and link the release here.
Activity
phase5 close + phase6 open: G16 recorded as a documented upstream res…
Tarquinen commentedon Oct 20, 2026
Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (
opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.— from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊