Skip to content

15 min read

Failure boundaries before your handler

On this page

An MCP tool call reaches your handler only after four messages have gone the right way, and each of those messages can be rejected for a reason that has nothing to do with what the tool does. That makes "the tool call returns an error" one of the least informative bug reports available, because the phrase covers a transport that never started, a version that never overlapped, a capability that was never declared, and a schema the two sides read differently — none of which involve the tool.

Narrowing it is a matter of finding the last message that arrived and asking what the next one should have been. Each gap between an expected message and a missing one is a different fault with a different owner, and the two instruments that answer the question are both cheap: a protocol trace, which is the sequence of JSON-RPC messages the two sides exchanged, and a log line you write yourself at the top of the handler. The trace says how far the request got. The log line says whether it arrived at all, and between them they partition every failure into one of seven places, six of which are not your tool.

What an empty handler log establishes

Put a line at the top of the handler and its absence becomes evidence:

python
@mcp.tool()
def create_invoice(customer_id: str, amount_cents: int, currency: str = "EUR") -> dict:
    """Create a draft invoice for a customer. Returns the invoice id and total."""
    log.info("create_invoice entered customer=%s amount=%s", customer_id, amount_cents)
    customer = billing.fetch_customer(customer_id)      # raises if unknown
    invoice = billing.create_draft(customer, amount_cents, currency)
    return {"invoice_id": invoice.id, "total_cents": invoice.total}

If that line never appeared, nothing below it ran. No exception was raised inside billing.fetch_customer, no timeout occurred talking to the billing API, no unhandled error in the invoice logic, because none of that code was reached. The whole class of explanations that live inside your tool is eliminated by one absent log line, which leaves the four-step exchange — initialize, the initialized notification, tools/list, tools/call — as the only place the failure can be. Absence of evidence is usually weak, and here it is not, because the evidence would have been written unconditionally on entry.

That word "unconditionally" is carrying the whole inference, and it is worth seeing why rather than taking it as a style preference. The reasoning runs backwards: if the line is absent then the statement did not execute, and if that statement did not execute then nothing below it did either, because the statements below are reached only by passing through it. The chain is sound only while there is no way for control to arrive below the line without passing through it, which is exactly what putting it on the function's first statement guarantees. Move it one line down, past a decode step or a validation call, and a failure in that step now produces the same silence as a call that never arrived — two different worlds emitting the same observation, and the inference collapses. The same collapse happens for a line inside an if, behind a sampling rate, or at a level the deployed configuration filters out. What turns an absent line into evidence is that exactly one world produces it.

Six boundaries, and the message that proves each one

BoundaryWhat went wrongProof in the traceUsually whose
Transportserver never started; wrong path, missing env, non-zero exitno JSON at all; child stderrclient config
Versionno shared protocol versioninitialize present, error or an unacceptable protocolVersion backeither
Handshake endclient read the response and gave upinitialize response, then no notifications/initializedclient
Capabilityserver declared no toolshandshake completes, no tools/list ever sentserver
Discoverytool missing, list truncated, name filteredtools/list result without the toolserver or client
Argumentarguments fail the declared schematools/call answered -32602schema mismatch

Six is the right count rather than an arbitrary one, and the way to convince yourself is to notice that the flowchart at the end of this section reaches the same six by a completely different route. The table was built by walking the four-step exchange and asking what each step can refuse for; the flowchart was built by asking what a reader can observe, starting from "is there any JSON at all". Those are different questions, and they land on the same set: a transport that produced nothing, a version that produced no agreement, a handshake the client abandoned, a capability never declared, a discovery result missing the tool, and arguments the schema refused. The flowchart then adds exactly one more terminal box, which is the handler, and that is the seventh place — the one the log line above establishes. Six plus one, with no eighth box anywhere, is what "partitions every failure" actually means: not that these are the failures somebody happened to list, but that a request has nowhere else to be.

The transport boundary is the crudest and the most common in the first hour of an integration. With stdio the client runs a command, and if that command exits immediately because the interpreter is not on the path, or the working directory is not what a sqlite3.connect("tickets.db") assumed, or an environment variable your module reads at import time is absent, then the pipe closes before a single byte of JSON is written. The client reports a connection failure with no protocol detail, because there is no protocol yet. Whatever your server wrote to stderr on the way down is the only diagnosis available, and whether the user ever sees it depends entirely on where their application decided to route a child process's stderr.

There is a variant of this that produces no error at all and wastes more time than the honest crash. Anything your server prints to stdout that is not a JSON-RPC message corrupts the stream, because stdout is the channel. A stray print() left in during debugging, a library that writes a deprecation notice, a progress bar from a downloader running at import time — each of these injects a line the client tries to parse as a message, and the result is either a -32700 parse error or a client that silently drops the line and waits forever for a response that already went past. The rule that follows is that on a stdio server every diagnostic goes to stderr without exception, and it is worth enforcing that with a logging configuration rather than with discipline.

The two outcomes of that corruption are worth separating, because one is survivable and the other eats an afternoon, and which one you get depends on where the stray line landed. The framing is one message per line, so the client's reader takes a line and hands it to a JSON parser. A stray line that is not valid JSON fails at the parser and can be reported, which is the -32700 case: unpleasant, but it names itself. A stray line that happens to be valid JSON — a library logging structured output, which is now the fashion — parses fine and is then looked up by its id. It has no id, or an id matching nothing outstanding, so a reasonable client discards it as unsolicited and reads the next line. Meanwhile the real response is still queued behind it and arrives one read later, which is fine, and nothing has broken at all until the day the stray line is emitted between a request and its answer often enough that a client with a bounded read loop gives up first. The failure that follows is a timeout on a call the server answered, and there is nothing in either log saying so. Structured logging to stdout is more dangerous here than a print, which inverts the usual advice for exactly one reason: on this transport, stdout is not an output stream, it is the wire.

Over HTTP the same boundary exists in a different costume. The failure is a 404 because the path is /mcp and the config said /mcp/, a 401 because the token expired, a proxy that buffers a streaming response until it is useless, or a load balancer that terminates an idle session at 60 seconds and leaves the client holding a connection the server has forgotten. None of those produce a JSON-RPC message either, which is why the first question against any trace is whether there was any protocol traffic at all — that single fork separates infrastructure problems from protocol problems, and they have different owners and different people to call.

The version boundary is real and is also the one people reach for too fast, which is why it deserves the sharpest statement. Skew produces a visible failure at initialize and nowhere else. If the trace shows an initialize response carrying a protocolVersion, then a version was proposed, considered and answered, and both sides had their say. A failure that happens later is not a version failure in any simple sense, and "tell them to upgrade" is advice that will consume a week and change nothing.

Why the version explanation is so attractive is worth saying, because it is not carelessness and it will be reached for again. Version skew is a real category, it explains a lot of trouble in most systems, and it has the rare property of being actionable without understanding anything — somebody upgrades, and either it helps or it does not. What makes it wrong here is that the negotiation is explicit and its outcome is written down. The client stated a version, the server answered with one, and both of those appear in the trace as fields you can read. A disagreement that both parties resolved in writing, in the first two messages, cannot be the cause of a failure in the fourth — and the trace either contains that resolution or it does not, which makes this the one boundary you can rule in or out without asking anybody anything. The test to run before saying the word version is to look for a protocolVersion in an initialize result. If it is there, the answer is no.

The handshake-end boundary is the one with no error message anywhere, because the thing that did not happen was a notification and notifications have no reply. The client sent initialize, read the response, decided it could not proceed on the terms offered, and stopped; there is no notifications/initialized in the trace and no further traffic in either direction. From the server's side that looks like a client that connected and then went quiet, which is also what a crashed client and a killed client look like. The trace narrows it to the boundary and no further, and the next question has to be put to whoever owns the client rather than answered from the wire.

The capability boundary is the quiet one. A server that registers its tools after constructing the server object, or that builds its tool list from a plugin directory which was empty in one deployment, answers initialize with a capability block that has no tools key. A compliant client reads that as "this server has no tools" and does exactly the right thing, which is to not ask. Everything looks healthy: the process is running, the session is open, the client shows the server as connected. There is simply nothing in it.

The reason nothing complains is worth stating as a chain, because the belief it defeats — that a connected server is a working server — is one almost every reader holds. The capability block is a declaration of what the server offers. A client that reads no tools key is not encountering an error; it is receiving a complete and well-formed answer that happens to say the server has no tools. Asking for a list anyway would be asking for something the server has just said it does not have, so a correct client does not ask. No request means no error, no error means nothing in any log, and a connection indicator is driven by the transport rather than by the contents of the handshake — so it shows green, correctly, because the connection genuinely is open. Every component behaves properly and the emptiness travels all the way to the user, which is the signature of this boundary: nobody is at fault anywhere along the path, and the fault is at the start of it. Nothing is surfacing the absence because no component along the way experienced one.

Discovery fails in more ways than it looks. The tool can be absent because registration threw during import and was swallowed. The list can be truncated because the server paginated and the client ignored the cursor. The tool can be present and then dropped by the client, since name prefixing and per-server allow-lists both happen after tools/list returns — a client that mangles create_invoice into billing__create_invoice and then fails to strip the prefix will send a tools/call for a name your server has never heard of, and get -32602 for a tool that exists.

The argument boundary is the last one before your code and is where schema disagreement surfaces. A tool that declares an unbounded integer and clamps it to 20 in the handler is the shape of it in miniature: the declaration and the behaviour disagreed, and nothing on the wire ever mentioned it. The version that produces a hard failure instead of a quiet one is a client that validates against your schema more strictly than you do — sending amount_cents as 1499.0 because its JSON layer has one number type, and getting rejected against {"type": "integer"} before your handler is entered.

Flowchart: Any JSON on the wire? → Transport: process never started (no); Any JSON on the wire? → initialize answered? (yes); initialize answered? → Version or malformed request (no / error); initialize answered? → initialized notification sent? (yes); initialized notification sent? → Client rejected the negotiated version (no); initialized notification sent? → tools/list sent? (yes); tools/list sent? → Server declared no tools capability (no); tools/list sent? → Tool present in the result? (yes); Tool present in the result? → Registration or pagination or client filter (no); Tool present in the result? → tools/call answered -32602? (yes); tools/call answered -32602? → Schema disagreement (yes); tools/call answered -32602? → Handler entered - your code (no).

One log line per session

The second half of removing that class rather than that instance is to make every session self-describing, on both sides:

python
@mcp.server.on_initialized
def log_session(ctx: SessionContext) -> None:
    log.info(
        "mcp session client=%s/%s protocol=%s tools=%d capabilities=%s",
        ctx.client_info.name, ctx.client_info.version,
        ctx.negotiated_version, len(mcp.list_tools()),
        sorted(mcp.capabilities()),
    )

One line per session, carrying who connected, which version was negotiated, how many tools were served and which capabilities were advertised. Against the failure above it would have read tools=0 capabilities=['logging'], and the diagnosis would have been available to whoever looked first, from either side. The client's half of the same discipline is to say what it found rather than what it could not do: an application that reports "connected to billing, 0 tools discovered" instead of "tool call returns an error" gives the user something they can pass on.

Schema disagreement has a particular flavour that catches everyone once, which is that JSON Schema is a large specification and implementations support different amounts of it. A server that declares format: "date-time" or a oneOf across three object shapes is relying on the client's validator, the model's ability to satisfy the constraint, and its own SDK's coercion all agreeing about what those keywords mean. When they do not, the argument arrives as a string where you expected a parsed datetime, or a call is rejected for a constraint the model had no way to infer from the description. Keeping declared schemas to the boring subset — flat objects, strings, integers, enums, arrays of one type — is not a limitation of the protocol so much as an acknowledgement that three independent implementations have to agree on every keyword you use.

There is an honest limit to how far a trace will take you. It shows what was said and not why either side decided to say it, so a client that stops after the handshake looks identical whether it read the capability block, hit an internal exception in its own discovery code, or was killed. It cannot see anything your process did before it wrote its first byte, which is where the whole transport class of failures lives. And it says nothing at all about whether the tool that ran returned the right answer — a tools/call with isError: false and a wrong result is invisible at this layer and always will be.

Tracing localises the fault to a boundary; it does not tell you what to do once you are inside one. That is still the larger part of the work, and it is why the log line at the top of the handler earns its place: it is the one observation that partitions the whole system into your code and everything before it, and it costs nothing to have.

What the six boundaries settle, taken together, is the argument the topic has been having about whether a protocol boundary is worth carrying. The connection succeeded and the tool was never reached, which is a failure mode that has no equivalent in a function call — an import either resolves or raises, and there is no state in which it resolves and serves nothing. That surface is part of the price of the decoupling, and whether the price is worth paying is the next thing to measure.

What MCP is and why it exists — read the current version at https://daviddominguez.dev/learn/en/what-mcp-is-and-why-it-exists/failure-boundaries-before-your-handler

Back to top