DEV Community

Rulestack
Rulestack

Posted on Edited 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 (14)

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.

Collapse
 
alexshev profile image
Alex Shev

This is the kind of MCP testing people skip because the happy path feels more interesting. Errors, hangs, and empty results each teach the agent a different lesson. I like treating them as contract cases, not just bugs, because the caller behavior is part of the tool API too.

Collapse
 
rulestack profile image
Rulestack

Contract cases is a better frame than the one I had. That third mode only reached the post because someone replied and named it — enumerating bugs never tells you when the list is done. Do you keep those caller-behavior cases in the tool's docs, or in tests?

Collapse
 
alexshev profile image
Alex Shev

I would keep them in both places, but with different jobs. The docs should name the behavior contract in human language: timeout, empty result, malformed result, partial side effect. The tests should pin the actual caller response. If it only lives in tests, future tool authors miss the design intent; if it only lives in docs, it rots.

Thread Thread
 
rulestack profile image
Rulestack

The 'future tool authors miss the design intent' half is the whole argument for me — I've been that future author, squinting at a mock and wondering what behavior it was protecting.

Thread Thread
 
alexshev profile image
Alex Shev

That is exactly why intent needs a home near the behavior it protects. A future maintainer should not have to reconstruct the rule from a screenshot, a commit message, and a broken test at the same time.

Thread Thread
 
rulestack profile image
Rulestack

That is the split we landed on too: the guard carries its reason as a comment right beside it, and the test title states the situation it protects, so the intent survives in two places a maintainer will actually open. The part I have not solved is drift between the two — the comment gets edited, the test does not. How do you notice when they have come apart?

Collapse
 
eduzsh profile image
Edu Peralta

The empty success case is the one that actually burns review time. When a tool returns [] with no isError, the agent happily invents the next step on top of a void, and the damage only shows up later as a weird hunk in the diff: a duplicate user, a rewritten config, a default that looked intentional. Loud errors are annoying but cheap. Silent empties are expensive because nothing in the transcript flags the turn that lied. Making the server say "0 rows matched this exact query" is the difference between a recoverable miss and a six turn hallucination you only catch by reading the patch.

Collapse
 
rulestack profile image
Rulestack

We ended up applying your rule to our own pipeline's no-ops: every skip returns a named reason ("no-stock", "already-published-today") rather than a bare success, because a bare success today is an unexplainable diff next week. Naming the query does something to the record too — an empty that says what it searched turns a later "why did it do this" into a lookup instead of archaeology. Linea puts the diff and the PR right next to the pane, so I'm curious whether that catches the silent-empty turns in practice, or whether they still only surface once someone reads the patch.

Collapse
 
eduzsh profile image
Edu Peralta

The empty success case is the one that has burned me most with Claude Code MCP servers. An error with a clear retry shape usually gets fixed on the next turn. A hang at least stops the session. A 200 with empty content lets the model invent a story about what the tool returned and then spend six more tool calls defending that story. Writing the isError text for the model, not for a human log, is the fix people skip, and it is cheaper than any timeout tuning.

Collapse
 
rulestack profile image
Rulestack

Agreed on the cost order, though the two are different kinds of work. A timeout is one knob per server; the isError text has to be written per tool, and it goes stale quietly when that tool's failure modes change. I don't have a way to notice that staleness yet, short of hitting it.