fix(opencode): surface MCP tools/list failure in status and logs
What
When an MCP server connects but its tool list fails to load, opencode currently throws the real error away and reports the string "Failed to get tools". The session then runs with zero tools from that server, for its entire life, and nobody can find out why.
This makes the failure diagnosable. It changes no control flow — the same sessions fail the same way — but the actual exception now reaches the logs and the server's status instead of being swallowed twice.
Why now
This is the root blocker on USE-2645, and it bit us in production again today.
In this thread, a Slack-triggered Replo agent was asked to file a ticket and answered "Linear isn't connected to this project, connect it in Integrations settings." Linear was connected — project scope, healthy, since July 31, never deleted. What had actually happened: the sandbox booted with bedrock=failed two minutes earlier, so every bedrock tool was missing (Linear, Slack, Grafana, PostHog, the Replo DB), and the model rationalized an empty toolbox into a confident, wrong explanation. That cost the thread an afternoon chasing a Linear integration that was fine.
The internal ops project behind every Slack-triggered session went from healthy through Aug 19 to 20 of 20 boots failing from Aug 20 onward. Fleet-wide it is roughly 1-9% of boots per day depending on the week.
The server side is provably innocent: bedrock-api serves tools/list as a 200 in under 100ms and its own per-group registration degrades gracefully. The failure is client-side in listTools, deterministic per sandbox, and content-keyed — which points at one malformed tool descriptor rejecting the whole strict parse. But that is a hypothesis, and it will stay a hypothesis until the swallowed exception is readable. This PR is what makes the next step possible rather than guesswork.
Changes
McpCatalog.defs now takes the server name (same argument order as the sibling fetch() helper) and logs the underlying error through Effect.tapError before swallowing it, mirroring the pattern fetch() already uses.
create() lists tools through the newly exported strict McpCatalog.listTools and maps the failure to Failed to get tools: <real message>, so the per-server failed status that GET /mcp reports carries the actual exception. It logs with the server key as well.
The OAuth authenticate() path keeps its combined !client || !listed handling and its generic status, but picks up the new defs log line for free.
Once this reaches the fleet, failed to get tools in Loki names the real exception within hours, and USE-2645 becomes a fixable bug instead of an open question.
Testing Done
New regression test in lifecycle.test.ts — "failed tool discovery surfaces the underlying error in the status": the server's tools/list throws boom-tools-list, and the test asserts the failed status contains it. Verified red without the src change (generic string) and green with it.
bun test test/mcp/ — 63 pass, 0 fail, under bun 1.4.0. typecheck clean; the only tsc errors in the repo are pre-existing ones in packages/tui/src/component/dialog-move-session.tsx, untouched here.
Rebased onto current dev. The branch previously carried a second, unrelated commit hardening Identifier.timestamp against the 48-bit wrap; it only existed to unblock the date-sensitive truncation test, which #24 has since fixed a different way, and Identifier.timestamp now has no callers in src/. Dropped to keep this PR single-purpose — recoverable at 7e6de952 if we want it separately.
Risk and Rollback
Diagnostics only. No behavior change for a healthy server, and a failing server fails identically — just with a readable reason. Rollback is a revert.
🤖 Generated with Claude Code
Summary by cubic
Surfaces MCP tools/list failures in opencode server status and logs so broken tool discovery is diagnosable. Previously, list errors were swallowed (generic “Failed to get tools”, no logs); now the real error is logged and included in the per-server failed status (addresses USE-2645).
McpCatalog.defs(clientName, client, timeout?)now logs the underlying error and returns undefined on failure; callers must pass the server name.- New exported
McpCatalog.listTools(client, timeout?);create()uses it, logs with the server key, and maps failures to “Failed to get tools: ”. - Tool list change handler calls
defs(name, client, timeout)to tag logs with the server; OAuth path keeps its combined handling but now benefits from thedefslog. - Tests updated; adds a regression asserting the failed status includes the underlying list error.