anomalyco / anomalyco/opencode

MCP: every tool-list failure collapses to "Failed to get tools" — the underlying error is discarded

Open
#47,644 0 comments 0 reactions 1 assignee View on GitHub

@kitlangton is already working on this.

Since Sep 6, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

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-160 retries with TolerantListToolsResultSchema only when isOutputSchemaValidationError(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 by defs().
  • if (!listed) can never fire for the "server advertises no tools" branch, because that branch yields [], and ![] is false. 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 (single application/json body) fails with the identical string;
  • it is not the network or the server: a raw JSON-RPC client in the same process completes initializenotifications/initializedtools/list (681 bytes of tool definitions) against that same listener;
  • the server-side handshake succeeded, so connect() resolved; the throw happens inside client.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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.