MCP in Production · Chapter 7

Errors that Help, Not Hide

The first time I watched Claude give up on a perfectly recoverable problem, I was sitting in front of one of the @yawlabs servers, debugging a tool call that returned a single useless line:

Error: Request failed with status code 404

The model read it, said "I'm sorry, I can't access that repository," and stopped. I had typed the repository name with a typo. The fix was a one-character edit. But the error didn't say "the repository name might be wrong" or "try checking spelling" or "use list_repos to see what you have access to." It said "404." So Claude did the only thing a reasonable assistant could do with that information: nothing.

That was the moment I understood that errors in MCP servers are not for me. They are for the model. And the model is not a developer with a stack trace and a debugger and twenty years of pattern-matching on HTTP status codes. The model is a reader, taking your error message at face value, and deciding what to do next based on the words you chose.

This chapter is about choosing those words. It covers the three error conventions MCP gives you, when to use each, and what each one looks like to the model that reads it. It draws on an audit I ran across the fourteen @yawlabs MCP servers I ship -- the six introduced in the front matter, plus the eight I haven't enumerated publicly -- scoring each server's error patterns against a rubric I will lay out later in the chapter. Some patterns made the model dramatically more competent. Some patterns made it give up on tasks it could have completed. The difference, almost always, was what the error message said and where it was emitted.

The three error conventions, and why they are not interchangeable

MCP gives you three ways to signal that something went wrong. They look similar from the outside but they reach the model very differently, and picking the wrong one is the single most common bug I find when grading a server.

Convention 1: tool result with isError: true

This is the convention you reach for almost every time. When a tool call reaches your handler, executes, and fails for any reason that is not a protocol violation, you return a normal tool result object with isError: true and content describing what went wrong:

tsserver.registerTool( "get_repo", { description: "Get a single GitHub repository by owner and name.", inputSchema: { owner: z.string(), repo: z.string() }, }, async ({ owner, repo }) => { // .catch((err) => err) returns octokit's RequestError on failure, which // carries .status -- so res is either the success response or that error object. const res = await octokit.repos.get({ owner, repo }).catch((err) => err); if (res.status === 404) { return { isError: true, content: [ { type: "text", text: `Repository ${owner}/${repo} not found. Check the spelling, or call list_repos to see repositories you have access to.`, }, ], }; } return { content: [{ type: "text", text: JSON.stringify(res.data) }] }; }, );

The model sees that text. It sees the repository name it tried, the suggestion to check spelling, and the name of the next tool to call. In practice, when I switched that server from generic 404s to this pattern, Claude's success rate on "find the README in my repo" tasks went from middling to near-perfect on a small eval set I keep in a private gist. The model wasn't smarter. The error was.

Convention 2: JSON-RPC error object

JSON-RPC errors are for protocol-level failures: the request itself was malformed, the method does not exist, the params do not match the schema your server advertised. The SDK handles most of these for you automatically. If the model calls get_repo with { owner: 123 } and your schema says owner: z.string(), the SDK rejects the call before your handler runs and returns a JSON-RPC -32602 Invalid params error to the client.

You almost never write these by hand. The exception is when you want to deny a call at the protocol level for reasons the schema cannot express -- for example, "this server is not yet initialized." In that case you throw an McpError from the SDK:

tsimport { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; if (!this.initialized) { throw new McpError( ErrorCode.InternalError, "Server is initializing. Retry in a few seconds.", ); }

The host sees a JSON-RPC error, not a tool result. Most hosts surface this differently than a tool error -- as a transport failure, a popup, or a flat refusal. The model often does not see the message at all, depending on how the host renders it. That is the right behavior for protocol violations. It is the wrong behavior for everything else.

Convention 3: throw, and let the SDK convert

If your handler throws an unhandled exception, the SDK catches it, wraps it in a JSON-RPC error, and sends it back to the host. The error code is -32603 Internal error and the message is whatever your exception said -- often something like TypeError: Cannot read properties of undefined (reading 'id').

This is the path that exists for safety, not for design. It catches bugs you didn't anticipate. It is not how you signal recoverable failures. The model sees, at best, a generic "internal error" and, at worst, nothing -- because many hosts log JSON-RPC errors to a developer console rather than surfacing them in the chat. The model has no idea why its tool call failed and cannot adapt.

From the field: the four-line rule

The shorthand I use when reviewing a server is the four-line rule. Look at any handler. If you can count more than four throw statements in the function body, you are almost certainly using throws where isError: true would do better. Throws are for "this code path should never execute and if it did the program is broken." Everything else -- bad inputs, missing resources, timeouts, rate limits, auth failures, anything the model could possibly recover from or learn from -- belongs in a returned tool result.

Why throw is almost always wrong in handlers

Walk through what happens when a handler throws. Your code raises an exception. The SDK's registerTool wrapper catches it. It packages it as a JSON-RPC error response. The host receives that response. The host decides what to do with it -- and host behavior here is wildly inconsistent.

Claude Desktop, in current versions, surfaces the error message to the model as an assistant-visible string in many cases. But the framing is different. The model sees something like "the tool call failed with an internal error" rather than the natural-language guidance you wrote. It does not see your error as part of the conversation; it sees it as a system signal that something is broken. And critically, the model cannot reliably parse the error message into a recovery plan -- because the message is delivered out of band.

Claude Code, the IDE-style host, often logs JSON-RPC errors to its developer panel without surfacing them to the model at all. The user sees a red squiggle in their terminal. The model sees nothing. It assumes the tool succeeded and silently produced no useful output, and proceeds to confidently generate a wrong answer.

When you return { isError: true, content: [...] }, none of that happens. The response is a normal successful JSON-RPC reply. The host hands it to the model as a tool result, exactly the way it would hand back a successful result. The isError: true flag tells the model "this didn't work" -- but the content array is exactly the channel you would use for a successful response, and the model treats it the same way: it reads the text, it incorporates it into its reasoning, and it decides what to do next.

Pitfall: A common mistake I see in early-stage servers is wrapping every handler in try { ... } catch (e) { throw new McpError(...) }. This is a no-op at best and harmful at worst. The SDK already catches throws. Wrapping a throw in a more specific throw just narrows the error code without changing the fact that the message goes to the host's error channel rather than the model's tool-result channel. If you want the model to see and react to the error, you have to return, not throw.

The rule I apply when grading my own servers, broken out by where the failure happens:

Servers that follow these rules score in the top quartile on grader runs. Servers that don't, regardless of how clean their code looks, score badly -- because the model running against them gives up too easily, or, in the module-scope case, never gets to run at all.

Specific errors beat generic errors, every single time

There is a temptation, especially for engineers used to writing libraries, to keep error messages terse and structured. "404 Not Found." "Validation failed." "Bad request." These are excellent error messages for a developer reading a log. They are useless error messages for a model trying to recover.

The bar I hold every error message to is: could a reader who did not write this code, and does not have the source open, plausibly figure out what to do next? "Repository foo/bar not found. Check spelling or call list_repos." passes. "GitHub returned 404." fails. "Repository not found." fails. "Invalid argument." fails dramatically.

Compare two real examples from the @yawlabs audit. From an early version of npmjs-mcp:

ts// Before return { isError: true, content: [{ type: "text", text: "npm registry returned 404" }], };

From the current version, after the audit:

ts// After return { isError: true, content: [ { type: "text", text: `Package "${pkg}" not found on the npm registry. Common causes: typo in the package name, scope missing (try "@scope/${pkg}" if it's a scoped package), or the package was unpublished. Use search_packages with a partial name to find similar packages.`, }, ], };

The before version produced a measurable failure rate on tasks like "is the package @yawlabs/aws-mcp on npm?" because Claude would respond with "the npm registry returned 404, the package may not exist" and stop. The after version produced near-perfect recovery: Claude tried the scoped form, then searched, then reported back. Same underlying API call, same underlying status code, completely different model behavior -- because the message included the next move.

Trigger phrases: tell the model what to do next

The biggest single improvement to error messages, across the audit, was including what I started calling "trigger phrases" -- short imperative sentences that name the next tool, the next parameter, or the next decision the model should make. The grammar matters: imperative voice, present tense, naming a specific tool by its actual MCP name.

Phrases that work:

Phrases that don't:

The trigger-phrase pattern is something I learned by watching transcripts. When the error included a tool name, the model called that tool more than 80 percent of the time. When the error said "check the documentation," the model apologized and stopped. The model is doing pattern matching on your prose. Give it patterns to match.

From the field: I keep a checklist next to my keyboard while I'm writing error messages: name the resource that failed, name the cause if known, name the next tool or action. Three nouns. If an error message has those three nouns, the model usually recovers. If it has only one or two, the model often gives up. It really is that mechanical.

The retry budget: encourage retry vs fail final

Errors fall, roughly, into two categories: transient and terminal. Transient errors -- network blips, rate limits, brief service outages, optimistic-concurrency conflicts -- are worth retrying. Terminal errors -- bad credentials, malformed inputs, hard 4xx responses on resources that don't exist -- are not. Retrying them just burns the model's context window on identical failures.

The error message you return should signal which category the error falls into. The model is reasonably good at honoring that signal if you give it.

For transient errors, end the message with explicit retry guidance:

tsreturn { isError: true, content: [ { type: "text", text: `GitHub API rate limit exceeded. Resets at ${resetTime} (in ~${secondsUntilReset}s). This is transient -- wait and retry the same call.`, }, ], };

For terminal errors, end the message with explicit "do not retry" guidance, and where possible point at the structural fix:

tsreturn { isError: true, content: [ { type: "text", text: `Authentication failed: GITHUB_TOKEN is missing or invalid. Do not retry -- this requires a configuration fix. The user needs to set GITHUB_TOKEN in their MCP server config.`, }, ], };

The phrase Do not retry is unambiguous and the model honors it. Without that phrase, especially on auth failures, I have watched Claude retry the same call seven times in a row, each time getting the same 401, each time burning context, each time getting more confused.

A useful framing: think of the model's tool-call budget as a finite resource you are spending on the user's behalf. Every retry costs context window space, latency, and (if there's a paid API behind the call) money. A clear "do not retry" signal on terminal failures is one of the cheapest performance wins available to you.

Rate-limit handling, in detail

Rate limits are the canonical case for "encourage retry, but with structure." The pattern that works across all fourteen @yawlabs servers looks like this:

tsasync function withRateLimitHandling<T>( fn: () => Promise<T>, context: { tool: string; resource: string }, ): Promise<{ ok: true; value: T } | { ok: false; error: string }> { try { const value = await fn(); return { ok: true, value }; } catch (err: any) { if (err.status === 429 || err.code === "RATE_LIMITED") { const retryAfter = parseRetryAfter(err.headers?.["retry-after"]); const seconds = retryAfter ?? 30; return { ok: false, error: `Rate limit hit on ${context.tool} for ${context.resource}. ` + `Wait ${seconds} seconds and retry the same call. ` + `If this happens repeatedly, reduce the frequency of ${context.tool} calls.`, }; } throw err; } }

Three things to notice. First, the error message names the tool that hit the limit -- that helps the model decide whether to back off generally or just retry this specific call. Second, it gives the wait duration as a number with units, not a vague "later." Models respect concrete numbers. Third, it includes the meta-suggestion "reduce frequency" for cases where the model is in a tight loop hitting the same endpoint repeatedly.

Detection of rate limits varies by upstream. Some return HTTP 429. Some return 200 with an error body. Some return 403 with a specific message. The handler is responsible for normalizing all of these into the same "rate limit hit" error shape, so the model sees a consistent signal regardless of which API misbehaved.

Network-layer errors: timeouts, aborts, and what the model sees

Network errors are the trickiest category because they originate below your handler -- in fetch, in the HTTP client, in the OS socket layer -- and by default they bubble up as exceptions with cryptic messages like FetchError: request to https://api.example.com/v1/foo failed, reason: socket hang up.

If you let those bubble up via throw, the model sees nothing useful. The right pattern is to install an AbortController with a timeout, catch the abort and the network errors at the handler boundary, and translate them into model-friendly text:

tsasync function fetchWithTimeout(url: string, opts: RequestInit, ms = 10_000) { const ac = new AbortController(); const timeout = setTimeout(() => ac.abort(), ms); try { return await fetch(url, { ...opts, signal: ac.signal }); } finally { clearTimeout(timeout); } } server.registerTool( "fetch_data", { description: "Fetch a record from the upstream API by id.", inputSchema: { id: z.string() }, }, async ({ id }) => { try { const res = await fetchWithTimeout(`https://api.example.com/v1/${id}`, {}); if (!res.ok) { return { isError: true, content: [ { type: "text", text: `Upstream API returned ${res.status}. ${describeStatus(res.status)} Retry the same call.`, }, ], }; } const data = await res.json(); return { content: [{ type: "text", text: JSON.stringify(data) }] }; } catch (err: any) { if (err.name === "AbortError") { return { isError: true, content: [ { type: "text", text: `Upstream API timed out after 10 seconds for id "${id}". This is usually transient -- retry once. If it times out again, the upstream service may be down.`, }, ], }; } if (err.code === "ECONNREFUSED" || err.code === "ENOTFOUND") { return { isError: true, content: [ { type: "text", text: `Could not reach upstream API (${err.code}). This usually means a network problem, not a code problem. Retry the same call in a few seconds.`, }, ], }; } return { isError: true, content: [ { type: "text", text: `Network error calling upstream API: ${err.message}. Retry once.`, }, ], }; } });

The structure here -- timeout via AbortController, named error categories with friendly text, fall-through for unknown errors -- is the same in every @yawlabs server. The describeStatus(503) helper returns "The upstream service is temporarily unavailable." That kind of plain-English status text is far more useful to the model than the bare HTTP code.

Pitfall: Setting timeouts too tight is a common early-stage mistake. I default to 10 seconds for read calls and 30 seconds for write calls. An early version of tailscale-mcp had a 3-second timeout on list_devices and was returning timeout errors regularly on networks where the Tailscale coordination server was slow. Loosening the timeout to 10 seconds dropped the error rate to roughly zero. The model can wait. The user is generally not blocking on a single tool call.

Validation: schema layer vs business-logic layer

There are two places where input can be rejected: at the schema layer, before your handler runs, and inside your handler, after schema validation passes but business rules don't.

The SDK gives you the schema layer for free via Zod. You declare:

tsserver.registerTool( "create_issue", { description: "Open a new GitHub issue on the given repository.", inputSchema: { repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/, "Must be in owner/repo format"), title: z.string().min(1).max(256), body: z.string().optional(), labels: z.array(z.string()).max(20).optional(), }, }, async (params) => { /* ... */ }, );

If the model calls create_issue with repo: "just-the-repo", the SDK rejects the call before your handler executes. The model receives a JSON-RPC error with the Zod message attached. In Claude Desktop and Claude Code, that message is reasonably visible -- the host renders it as part of the tool failure -- and the model usually corrects on its next attempt.

The schema layer is the right place for everything that can be expressed as a structural constraint: types, ranges, enum values, regex patterns, required vs optional. Use it aggressively. The more you push into Zod, the less your handler has to validate by hand, and the cleaner your handler code stays.

But there is a class of validation that schemas cannot catch: business-logic constraints that depend on runtime state. "This issue title is already in use." "This deployment slot is locked." "This package version was already published." These rejections happen inside your handler, after the call has reached your code. They should return isError: true, not throw, and they should follow all the rules from above -- specific resource, named cause, named next action.

tsconst existing = await findIssueByTitle(repo, title); if (existing) { return { isError: true, content: [ { type: "text", text: `An issue with title "${title}" already exists in ${repo} ` + `(issue #${existing.number}). Either pick a different title, or ` + `call get_issue with number=${existing.number} to view the existing one.`, }, ], }; }

The line between schema and business is sometimes blurry. My rule of thumb: if you can validate it without making any external call, push it into Zod. If you need to talk to a database or an upstream API to validate, do it in the handler.

Logging errors without leaking secrets

Chapter 4 covered the redaction pattern in detail. The short version: every server has a redact() function that takes a log line and removes anything matching known secret patterns -- API tokens, JWTs, basic-auth headers, signed URLs. Every log call in the server runs through it.

Error handling is where redaction earns its keep. When you catch an exception from an upstream API client, the exception often carries the full request including headers. If you log that exception verbatim, you are about to log the auth token used to make the request. I have watched this happen on real production servers. The token ends up in CloudWatch, in Sentry, in stdout that gets piped to a file someone forgot to set permissions on, and now you have a credential leak.

The pattern I use:

tsfunction safeLogError(context: string, err: unknown) { const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err); const redacted = redact(detail); console.error(`[error] ${context}: ${redacted}`); // Stack trace separately so a malformed stack doesn't poison the main log line. if (err instanceof Error && err.stack) { console.error(`[stack] ${redact(err.stack)}`); } }

Three rules:

  1. The log goes to stderr, not stdout. Stdout is the JSON-RPC channel -- anything you write there will corrupt the protocol stream. This is one of the most common mistakes I see.
  2. The error message goes through redact() before it hits the log. No exceptions, even for "internal" errors.
  3. The error message returned to the model is separate from the log message. The log is for me, the human operator. The returned error is for the model. Sometimes they should be different -- the log can include diagnostic detail that the model doesn't need or shouldn't see.

From the field: the very first leak I caused on a YawLabs server was logging the full axios error object on aws-mcp's list_buckets failure. The object's config.headers field included the AWS signed URL, including the signature. Anyone with read access to the log file had a working presigned URL for sixty seconds. Nobody was harmed -- I caught it within an hour during a self-review -- but the experience permanently changed how I think about error logging. Errors are exactly the moment when you are most likely to leak, because they are exactly the moment when you reach for full context to debug.

A compliance rubric for error handling

The rubric I run against every @yawlabs server before release scores error handling on six axes. Each axis is worth up to ten points. Servers below 40 out of 60 are not allowed to ship. Servers below 50 get a "needs work" tag in the release notes.

The six axes:

  1. Specificity (10 pts). Random-sample five error messages from the server. For each, ask: does the message name the specific resource that failed? "Repository foo/bar not found" scores. "Resource not found" does not. Two points per sample for full specificity, one for partial, zero for generic.

  2. Trigger phrases (10 pts). For each of the same five messages, does the message tell the model what to do next, naming a specific tool or parameter? Two points per sample.

  3. Retry guidance (10 pts). Sample five error paths in the source. For each, is there an explicit signal whether the error is transient ("retry") or terminal ("do not retry")? Two points per sample.

  4. Throw discipline (10 pts). Count throws in handler bodies (excluding the SDK's own internal throws). Zero throws: 10 points. One to two throws: 7 points. Three to five: 4 points. More than five: 0 points.

  5. Secret hygiene (10 pts). Inspect the redaction pattern. Does the server have a redact() helper applied to all error logging? Are stack traces redacted? Are upstream API error objects redacted before logging? Pass-fail across three sub-checks, with partial credit.

  6. Network-layer coverage (10 pts). For network calls, do they have timeouts? Do they handle ECONNREFUSED, ENOTFOUND, AbortError, and HTTP 5xx as separate cases with separate messages? Two points per category covered.

When I first ran this on the @yawlabs servers, the median score was in the low 30s. After the audit and the rewrites it triggered, the median sits in the low 50s -- roughly a doubling. The two outliers worth calling out were one of the unannounced three at the high end (which had been written with the rubric in mind from day one) and lemonsqueezy-mcp on the low end, which still has weak network-layer handling -- the LemonSqueezy API client throws several different exception classes for the same underlying condition, and normalizing them is exactly the kind of follow-up work this rubric surfaces. It still clears the 40-point floor: it scores well on the other five axes and loses most of its points on the network-layer one.

If you are writing your own server, score it against this rubric -- the axes are simple enough to apply by hand. The rubric is opinionated, but it correlates strongly with how Claude actually behaves against a server in practice.

The fourteen-server audit: patterns that worked, patterns that didn't

I ran the same eval set -- about 200 tasks of varying difficulty -- against each of the fourteen servers, twice. Once before the audit, once after the rewrites. Here are the patterns that moved the needle the most, with concrete examples.

What worked: naming the failed resource in the message

Across every server, replacing "X failed" with "X failed for {specific thing}" produced a substantial improvement. One server's get_pr went from a generic "PR not found" to "PR #1234 not found in YawLabs/spend." The model's recovery rate on tasks involving wrong PR numbers tripled -- from confused apology to "let me check, maybe the PR is in a different repo" to "let me list the recent PRs."

What worked: naming the next tool

A database-query server in the portfolio previously returned "syntax error in SQL" for any SQL parse failure. After the audit, it returns "syntax error in SQL near '{token}'. Use describe_schema to see the available tables and columns, or list_tables for a high-level view." The model's success rate on schema-discovery tasks doubled.

What worked: explicit do-not-retry signal on auth failures

aws-mcp had a particularly bad pre-audit failure mode: when AWS credentials were missing or invalid, the SDK threw an UnauthorizedException that bubbled up as a JSON-RPC internal error. The model often retried, sometimes up to ten times, each time burning context. After the rewrite, the first auth failure returns:

AWS credentials are missing or invalid (got UnauthorizedException). Do not retry -- this is a configuration problem. Ask the user to set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in their MCP server config, or use AWS_PROFILE to point at a configured profile.

The retry rate on auth failures dropped from a majority of attempts to near-zero. The few remaining retries were the model checking once with a different parameter -- which is fine, that's the model being thorough.

What didn't work: structured error codes

For a brief period I experimented with returning structured error objects in the content array -- something like { type: "text", text: JSON.stringify({ code: "REPO_NOT_FOUND", details: ... }) }. The thinking was that the model could parse the structured data and react to it more reliably than to prose.

It didn't work. The model is much better at reading prose than at parsing JSON in tool results. Structured codes confused it. It would either ignore the JSON and apologize, or try to parse the JSON and get tangled in escape characters. Plain English wins.

The exception is when the structured data is the actual content -- a list of items, a record from a database. There, JSON is fine, because the model treats it as data, not as an instruction. But for error metadata, prose is the medium.

What didn't work: stack traces in the message

Another failed experiment: including a redacted stack trace in the model-visible error text, on the theory that "more context is better." It made things worse. The model would sometimes try to interpret stack frames as something it should act on -- "the error originated in node_modules/octokit/..." -- and would invent strange recovery strategies based on that. Stack traces belong in your stderr log, not in the model's view.

What didn't work: long pre-amble in error messages

A version of npmjs-mcp briefly had error messages like "We're sorry, but it looks like the package you requested could not be found. This might be due to a number of reasons including..." The verbose, conversational pre-amble actively reduced model recovery rates. The model spent its attention parsing the apology rather than picking up the trigger phrases at the end.

The format that consistently won: one declarative sentence about what failed, one sentence about likely cause if known, one sentence about what to do next. Three sentences. No apology. No "please." No "we're sorry." Just facts and instructions.

What worked, surprisingly: the word "this"

This one I almost left out because it sounds silly, but the audit data was clear. Error messages that use the demonstrative "this" -- "This is transient, retry the same call" -- outperformed otherwise-identical messages that used "the" or "it." The model parses "this" as a strong anaphoric reference to the failure just described and weights its next-step decision accordingly. I have no good theoretical explanation. I just know that across thousands of test runs, "this is transient" beat "the error is transient" reliably enough that I stopped questioning it.

Putting it together: a complete error-handling layer

Here is the pattern I now copy into every new @yawlabs server on day one:

tsimport { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { redact } from "./redact.js"; type ToolError = { isError: true; content: [{ type: "text"; text: string }] }; function toolError(text: string): ToolError { return { isError: true, content: [{ type: "text", text }] }; } function safeLog(level: "error" | "warn", context: string, detail: unknown) { const text = detail instanceof Error ? `${detail.name}: ${detail.message}` : String(detail); const stream = level === "error" ? console.error : console.warn; stream(`[${level}] ${context}: ${redact(text)}`); if (detail instanceof Error && detail.stack) { stream(`[stack] ${redact(detail.stack)}`); } } async function callUpstream<T>( label: string, fn: () => Promise<T>, opts: { timeoutMs?: number } = {}, ): Promise<{ ok: true; value: T } | { ok: false; error: ToolError }> { const ac = new AbortController(); const timeout = setTimeout(() => ac.abort(), opts.timeoutMs ?? 10_000); try { const value = await fn(); return { ok: true, value }; } catch (err: any) { safeLog("error", label, err); if (err.name === "AbortError") { return { ok: false, error: toolError( `${label} timed out after ${(opts.timeoutMs ?? 10_000) / 1000}s. This is usually transient -- retry once.`, ), }; } if (err.code === "ECONNREFUSED" || err.code === "ENOTFOUND") { return { ok: false, error: toolError( `${label} could not reach the upstream service (${err.code}). This is a network issue, not a code issue. Retry in a few seconds.`, ), }; } if (err.status === 401 || err.status === 403) { return { ok: false, error: toolError( `${label} failed: authentication rejected (HTTP ${err.status}). Do not retry -- this requires a credentials fix.`, ), }; } if (err.status === 429) { const retryAfter = err.headers?.["retry-after"] ?? "30"; return { ok: false, error: toolError( `${label} hit a rate limit. Wait ${retryAfter} seconds and retry the same call.`, ), }; } if (err.status >= 500 && err.status < 600) { return { ok: false, error: toolError( `${label} got an upstream error (HTTP ${err.status}). This is usually transient -- retry once.`, ), }; } return { ok: false, error: toolError( `${label} failed: ${redact(err.message ?? "unknown error")}. Retry once; if it persists, the upstream may be down.`, ), }; } finally { clearTimeout(timeout); } }

A handler using this looks like:

tsserver.registerTool( "get_repo", { description: "Get a single GitHub repository by owner and name.", inputSchema: { owner: z.string(), repo: z.string() }, }, async ({ owner, repo }) => { const result = await callUpstream( `get_repo(${owner}/${repo})`, () => octokit.repos.get({ owner, repo }), { timeoutMs: 8000 }, ); if (!result.ok) return result.error; if (!result.value.data) { return toolError( `Repository ${owner}/${repo} not found. Check spelling, or use list_repos to see repositories you have access to.`, ); } return { content: [{ type: "text", text: JSON.stringify(result.value.data) }] }; }, );

The handler is short. Every error path returns rather than throws. Every message is specific to the resource. Every transient error tells the model to retry; every terminal error tells the model not to. Logs go to stderr, redacted. This is the shape every server in the @yawlabs lineup converged on after the audit.

A small error taxonomy for the log channel

Everything above is about the error message the model sees. The error message you see -- in your stderr log, in your aggregator, in the CSV your security team will eventually ask for -- has a different shape. The model wants prose; the operator wants categories.

I keep a short, fixed taxonomy of error codes per server. Three to seven categories, no more. Mine across the @yawlabs servers:

The discipline is that every error path in your handlers tags its log line with one of these codes, and your weekly health check is one number per code. INTERNAL rate this week is the one I look at first. If it's drifting upward, something I shipped recently is misbehaving and I want to know which release before customers tell me.

The model never sees the code. It sees the prose. The code is for the log channel, the dashboards, and the post-incident review. Keep the two separate; they are doing different jobs for different readers, and conflating them is how you end up with prose error messages that look like enum values and structured logs that read like apologies.

tstype ErrorCode = | "BAD_INPUT" | "UPSTREAM_FAIL" | "UPSTREAM_TIMEOUT" | "RATE_LIMITED" | "AUTH_FAIL" | "INTERNAL"; function logToolError(tool: string, code: ErrorCode, detail: unknown) { const text = detail instanceof Error ? `${detail.name}: ${detail.message}` : String(detail); process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), level: "error", event: "tool_call", tool, error_code: code, error: redact(text), }) + "\n"); }

Three to seven codes; no more. The temptation to add a new code every time something new fails is the temptation to convert your taxonomy back into prose. Resist. If you find yourself wanting an eighth code, look at the seven you have and ask whether one of them is doing two jobs that should be split, or whether the new failure mode actually fits a code you already have. The taxonomy is a coarse classifier on purpose; the message text is where the resolution carries.

Closing: errors are a UX surface

The lesson I keep coming back to is that error messages in MCP servers are not a developer concern. They are a user experience concern, where the user is the model. The model is reading your prose, weighing your words, deciding how confidently to proceed. If your errors are vague, the model is timid. If your errors are specific and actionable, the model is competent.

This is uncomfortable for engineers raised on terse logs and precise stack traces. We are used to errors being signals to other engineers, optimized for diagnostic density. MCP errors are different. They are signals to a reader who will not debug, will not browse documentation, will not ssh into a box. The reader has only the words you wrote, and one shot to decide what to do.

Treat them like prompts. Edit them like copy. Test them with the model. Watch what happens when the trigger phrase is missing versus present. The improvements compound: a server with good error handling feels two or three times more capable than a server with bad error handling, even if the underlying tool surface is identical.

In the next chapter, we'll move from the messages your server emits to the tests that prove your server still emits the right ones after you change it. Testing an MCP server is not testing a REST API -- the consumer is probabilistic, and that changes which layers of testing earn their keep. The error patterns from this chapter become assertions in the next one: the eval suite is where you find out whether the trigger phrases you wrote still trigger the recovery behavior they did the day you shipped them.