Chapter 5 of 8 · 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:
@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
| Boundary | What went wrong | Proof in the trace | Usually whose |
|---|---|---|---|
| Transport | server never started; wrong path, missing env, non-zero exit | no JSON at all; child stderr | client config |
| Version | no shared protocol version | initialize present, error or an unacceptable protocolVersion back | either |
| Handshake end | client read the response and gave up | initialize response, then no notifications/initialized | client |
| Capability | server declared no tools | handshake completes, no tools/list ever sent | server |
| Discovery | tool missing, list truncated, name filtered | tools/list result without the tool | server or client |
| Argument | arguments fail the declared schema | tools/call answered -32602 | schema 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.
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:
@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