Skip to content

fix(opencode): surface MCP tools/list failure in status and logs - #20

Merged
kevoconnell merged 1 commit into
devfrom
surface-tools-error
Aug 24, 2026
Merged

kevoconnell merged 1 commit into
devfrom
surface-tools-error

Conversation

@kevoconnell

@kevoconnell kevoconnell commented Aug 20, 2026 •

Copy link
Copy Markdown
Member

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 the defs log.
  • Tests updated; adds a regression asserting the failed status includes the underlying list error.

Written for commit 5a0ab51. Summary will update on new commits.

Review in cubic


Open in Devin Review

@github-actions

Copy link
Copy Markdown

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@github-actions

Copy link
Copy Markdown

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown

The following comment was made by an LLM, it may be inaccurate:

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/mcp/index.ts">

<violation number="1" location="packages/opencode/src/mcp/index.ts:412">
P3: The new `failed to get tools` log in create() records the server under field `key`, but the identical log in McpCatalog.defs (used by watch() and authenticate()) records it under `clientName`. A Loki query filtering by one server field misses failures logged by the other path, which weakens the exact observability this PR is adding. Log the server name as `clientName` here to match defs()/fetch().</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

// status and a log line, instead of an unexplained "Failed to get tools".
const listed = mcpClient.getServerCapabilities()?.tools
? yield* McpCatalog.listTools(mcpClient, mcp.timeout).pipe(
Effect.tapError((error) => Effect.logWarning("failed to get tools", { key, error: error.message })),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new failed to get tools log in create() records the server under field key, but the identical log in McpCatalog.defs (used by watch() and authenticate()) records it under clientName. A Loki query filtering by one server field misses failures logged by the other path, which weakens the exact observability this PR is adding. Log the server name as clientName here to match defs()/fetch().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/mcp/index.ts, line 412:

<comment>The new `failed to get tools` log in create() records the server under field `key`, but the identical log in McpCatalog.defs (used by watch() and authenticate()) records it under `clientName`. A Loki query filtering by one server field misses failures logged by the other path, which weakens the exact observability this PR is adding. Log the server name as `clientName` here to match defs()/fetch().</comment>

<file context>
@@ -405,10 +405,14 @@ const layer = Layer.effect(
+          // status and a log line, instead of an unexplained "Failed to get tools".
+          const listed = mcpClient.getServerCapabilities()?.tools
+            ? yield* McpCatalog.listTools(mcpClient, mcp.timeout).pipe(
+                Effect.tapError((error) => Effect.logWarning("failed to get tools", { key, error: error.message })),
+                Effect.mapError((error) => new Error(`Failed to get tools: ${error.message}`)),
+              )
</file context>
Suggested change
Effect.tapError((error) => Effect.logWarning("failed to get tools", { key, error: error.message })),
Effect.tapError((error) => Effect.logWarning("failed to get tools", { clientName: key, error: error.message })),

@kevoconnell

Copy link
Copy Markdown
Member Author

CI note: the two red jobs (test, unit (linux)) both fail on a single pre-existing test — Truncate > cleanup > deletes files older than 7 days and preserves recent files (test/tool/truncation.test.ts:260, the recent file is unexpectedly deleted). It fails identically on clean dev with no changes (verified locally under bun 1.3.14), so it is unrelated to this PR. All test/mcp/ suites pass (63/63) and typecheck is clean.

@kevoconnell

Copy link
Copy Markdown
Member Author

Pushed 7e6de95 fixing the red CI at its root instead of waiting it out: Identifier.create packs ms * 0x1000 + counter into 48 bits, so encoded timestamps wrap every 2^36 ms (~795 days) — last boundary 2026-08-14T10:39:55Z. Identifier.timestamp returned the raw wrapped value, so decodes straddling the boundary were ~795 days off: Truncate.cleanup deleted files younger than 7 days (real prod misbehavior for a 4-day window every ~795 days, live right now), and its test has been red on every branch since Aug 17 — it would have silently self-healed tomorrow morning and recurred in 2028.

timestamp() now anchors the wrapped value to the current era (minus one wrap when it would land >1h in the future), exact for IDs younger than ~795 days — its only two callers are the cleanup cutoff and entry ages. New deterministic regression test round-trips IDs on both sides of the boundary (fails on any calendar date without the fix, verified red→green); the previously-failing cleanup test now passes, test/id + test/tool + test/mcp all green under bun 1.3.14, typecheck clean.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/id/id.ts">

<violation number="1" location="packages/opencode/src/id/id.ts:90">
P2: When a pre-wrap ID is within the one-hour skew window, this condition returns the current-era timestamp instead of unwrapping it, producing an error of about 795 days even though the ID is within one encoding wrap. `Truncate.cleanup` can then retain those old files for an extra retention window; pass a caller age/cutoff bound to disambiguate pre-wrap IDs from future-skewed IDs.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/opencode/src/id/id.ts Outdated
const wrapped = BigInt("0x" + hex) / BigInt(0x1000)
const now = BigInt(Date.now())
const candidate = (now / TIMESTAMP_WRAP_MS) * TIMESTAMP_WRAP_MS + wrapped
return Number(candidate > now + TIMESTAMP_SKEW_MS ? candidate - TIMESTAMP_WRAP_MS : candidate)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a pre-wrap ID is within the one-hour skew window, this condition returns the current-era timestamp instead of unwrapping it, producing an error of about 795 days even though the ID is within one encoding wrap. Truncate.cleanup can then retain those old files for an extra retention window; pass a caller age/cutoff bound to disambiguate pre-wrap IDs from future-skewed IDs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/id/id.ts, line 90:

<comment>When a pre-wrap ID is within the one-hour skew window, this condition returns the current-era timestamp instead of unwrapping it, producing an error of about 795 days even though the ID is within one encoding wrap. `Truncate.cleanup` can then retain those old files for an extra retention window; pass a caller age/cutoff bound to disambiguate pre-wrap IDs from future-skewed IDs.</comment>

<file context>
@@ -69,12 +69,25 @@ export function create(prefix: string, direction: "descending" | "ascending", ti
+  const wrapped = BigInt("0x" + hex) / BigInt(0x1000)
+  const now = BigInt(Date.now())
+  const candidate = (now / TIMESTAMP_WRAP_MS) * TIMESTAMP_WRAP_MS + wrapped
+  return Number(candidate > now + TIMESTAMP_SKEW_MS ? candidate - TIMESTAMP_WRAP_MS : candidate)
 }
 
</file context>

@github-actions

Copy link
Copy Markdown

This pull request has been automatically closed because it was not updated to meet our contributing guidelines within the 2-hour window.

Feel free to open a new pull request that follows our guidelines.

A failed tools/list during MCP server creation was swallowed twice:
McpCatalog.defs dropped the exception unlogged and create() collapsed it
to a constant 'Failed to get tools' status. Fleet sandboxes have been
reporting bedrock=failed with no way to see why (USE-2645).

defs() now logs the underlying error (tagged with the server name, like
the sibling fetch() helper), and create() lists tools via the strict
McpCatalog.listTools so the real message lands in both a log line and
the failed status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@github-actions

Copy link
Copy Markdown

This pull request has been automatically closed because it was not updated to meet our contributing guidelines within the 2-hour window.

Feel free to open a new pull request that follows our guidelines.

@github-actions github-actions Bot closed this Aug 24, 2026
@kevoconnell kevoconnell reopened this Aug 24, 2026
@kevoconnell
kevoconnell merged commit 6401806 into dev Aug 24, 2026
23 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant