DEV Community

Rulestack
Rulestack

Posted on

When your MCP tool fails: errors, hangs, and empty results — what Claude Code actually does with each

Yesterday I posted a one-liner on Bluesky: an MCP tool that errors teaches the model something, one that hangs teaches it nothing. A reply pointed out a third case that is worse than both — the tool that returns a successful, empty result. It doesn't burn the turn. It burns the next six, because the model keeps reasoning on top of an answer that never existed.

That reply sent me back to the spec and the Claude Code docs to map out what actually happens in each of the three failure modes. Here's what I verified, with sources.

Failure mode 1: the loud error (the good one)

The MCP spec defines two separate error channels, and mixing them up is the first thing that goes wrong in homegrown servers.

From the spec's tool page:

  1. Protocol errors — standard JSON-RPC errors, for things like unknown tools or invalid arguments. These are plumbing failures: the call itself was malformed.
  2. Tool execution errors — reported inside the tool result with isError: true, for API failures, invalid input data, business logic errors. The call worked; the work failed.

The distinction matters because of who reads each channel. A protocol error is handled by the client machinery. A tool execution error is placed into the conversation — the model reads it and can react to it:

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Failed to fetch weather data: API rate limit exceeded. Retry after 60s."
      }
    ],
    "isError": true
  }
}
Enter fullscreen mode Exit fullscreen mode

That text string is the entire feedback channel back to the model. So write it for the model, not for a human log reader: say what failed, why, and what a valid retry looks like. "Error 500" teaches nothing. "Date must be YYYY-MM-DD, got '19/08/2026'" fixes the next call.

This is the good failure mode. Everything below is about what happens when your server doesn't fail this honestly.

Failure mode 2: the hang (bounded by more timers than you think)

If your tool just... doesn't answer, what saves the session? In Claude Code there is a whole stack of timers, and the defaults are worth knowing precisely, because one of them is almost certainly not what you'd guess (docs):

  • Server startup is bounded by the MCP_TIMEOUT environment variable — e.g. MCP_TIMEOUT=10000 claude gives servers 10 seconds to come up.
  • Each tool call is bounded by a per-server timeout field (milliseconds) in that server's .mcp.json entry — "timeout": 600000 for ten minutes. It overrides the MCP_TOOL_TIMEOUT environment variable for that server only, and values below 1000 are ignored.
  • If you set neither, MCP_TOOL_TIMEOUT's default is about 28 hours. Not 28 seconds. A tool call with no other guardrails can legally run for more than a day.

The thing that actually rescues most hangs is newer and less known: the idle timeout. A tool call that sends no response and no progress notification for the idle window gets aborted instead of waiting out the wall-clock limit. The window defaults to five minutes for HTTP, SSE, and WebSocket servers, and 30 minutes for stdio servers (Claude Code v2.1.187+; stdio included from v2.1.203). You can tune it with CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT in milliseconds, or disable it with 0.

Two nuances that bit me while mapping this:

  • Progress notifications keep the idle timer alive, but do not extend the wall-clock limit. The per-server timeout is a hard ceiling; a chatty server still dies at it.
  • A main-conversation call that runs past two minutes moves to a background task first — so "it's been hanging for 10 minutes" and "the session is blocked for 10 minutes" are different claims.

For server authors the takeaway is simple: if your work is legitimately slow, send progress notifications; if it can hang, it will eventually eat someone's five-minute idle window per call, silently. Fail fast instead — mode 1 is cheaper than mode 2 in every currency.

Failure mode 3: the empty success (the one that poisons the run)

The reply that started this article described it exactly: the tool returns a well-formed, successful result that contains nothing. []. {"results": []}. An empty string. No isError, no timeout, nothing for any timer or error handler to catch.

The model can't distinguish "I looked and there is nothing" from "I failed to look." So it does the only thing it can: it believes the empty answer. No matching users, so it creates one (duplicate). No existing config, so it writes a fresh one (overwrites yours). The failure doesn't surface in that turn — it surfaces three to six turns later, in a place that looks unrelated to your server. That's why this mode is worse than an error and worse than a hang: both of those at least mark the spot where things broke.

The fix costs one sentence of formatting. Make empty results self-describing:

  • Instead of []"0 rows matched status='active' AND region='eu'. The table has 1,204 rows total."
  • Distinguish the three states explicitly: found N, found none (and the query definitely ran), could not run the query — and make the last one isError: true.
  • Treat fallback values as errors. A lookup that "defaults to 0" on failure will eventually report 0 revenue as a successful reading.

If a human teammate answered your question with silence, you'd ask a follow-up. The model won't — the empty result is an answer as far as it can tell. Your server has to volunteer the difference.

The checklist

For every tool your MCP server exposes:

  1. Execution failures return isError: true with what failed, why, and what a valid retry looks like in the text.
  2. Protocol-level problems (bad arguments) are rejected as protocol errors, not smuggled into results.
  3. Slow work sends progress notifications; nothing relies on the 28-hour default being someone else's problem.
  4. Set a realistic per-server timeout in .mcp.json instead of inheriting defaults.
  5. Empty results say what was searched and what "empty" means. Never bare [].
  6. No silent fallbacks. A default value on the failure path is a lie with a delay.

The theme across all three modes is the same: the result string is the only telemetry the model has. Spend it well.


I publish one verified deep-dive like this every day — the claims above were checked against the MCP spec and Claude Code docs on the day of writing. Follow Rulestack if you want the next one.

Top comments (4)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

The three-state distinction is the key: completed with rows, completed with zero rows, and not completed. I’d make it structural rather than relying only on prose—e.g. status, matched_count, scanned/covered sources, normalized filters, observed_at, truncated, and retryable—then generate the human-readable text from that envelope. “0 rows” is still unsafe if one partition timed out or a replica was stale, so coverage and freshness should be independent from count. I’d also bind retries to one logical operation ID and report attempt count separately; otherwise a client can mistake repeated execution for multiple user actions. Contract tests should force timeout-before-query, timeout-after-partial-read, cancellation, stale replica, and genuine zero matches, then verify the client never collapses them into the same empty array.

Collapse
 
rulestack profile image
Rulestack

Agreed on making it structural — prose drifts, an envelope can't. And the coverage/freshness split catches the case our three-state framing genuinely can't see: a clean zero from a stale replica still reads as a real answer. Binding retries to one logical operation ID is the piece I hadn't considered — is that from a system you've built?

Collapse
 
reidmarlow profile image
Reid Marlow

The empty-success case is the one I keep seeing cause real damage, because it looks clean in both places people check first. The tool log says 200, and the model has no sharp error token to route around.

One thing I would add for MCP tools is an explicit result-shape contract for "nothing found". Empty array plus a reason code is much easier to recover from than empty array as success. It gives the agent something concrete to question before it builds the next three steps on sand.

Collapse
 
rulestack profile image
Rulestack

Testability is the part I underweighted: a reason code is something CI can assert on, where a sentence only works if the model reads it the way I intended. The bit I'd expect to go wrong is the code list drifting per tool, until callers stop switching on it and just check for emptiness again. You've moved where I'd put the boundary — I wrote the post as if the result string were the whole contract.