Skip to content
Chapter 5 of 8 · Tools over MCPProtocol errors and tool errors

13 min read

Protocol errors and tool errors

On this page

Choosing between them is a design decision about who needs to know, and it is made per failure rather than once per server. The distinction is easy to state and easy to get wrong by default, because most servers end up in a channel by accident rather than by decision. A framework wraps your handler in a try block so that an exception cannot kill the connection, and the wrapper has to pick a channel on your behalf. raise InvoiceNotFound(...) is not a statement about who should read the failure, and whichever channel the wrapper picks is the one your users get.

The instinct that produces this is a good one applied one layer too far down. In ordinary code an exception is exactly right for a missing record: it separates the failure path from the success path, it carries a type the caller can match on, and it stops the function returning something meaningless. All of that still holds inside your server. What changes at the boundary is that raise is now being asked to answer a question it was never designed to answer, which is who should be told. An exception says something went wrong in the code. The channel decision says which of two readers — a client library, or a model in a conversation — has to act on it. Those are different questions, and a raise that answers the first hands the second to whatever default a framework happens to have.

Getting it wrong in the direction of protocol errors is the expensive direction, because the model is left with no information about what happened and the condition it does infer — something internal went wrong — is one that genuinely is worth retrying. The result is a loop that costs tokens and time and ends in a confident, false statement to the user.

Two shapes on the wire

A protocol error replaces the result entirely. The response carries an error object instead of a result, and the MCP client — a library, not the model — is what receives it:

json
{
  "jsonrpc": "2.0",
  "id": 12,
  "error": {"code": -32603, "message": "Internal error"}
}

A tool error is a successful protocol exchange whose payload says the operation failed. The call reached your handler, the handler ran, and the outcome was negative; isError: true marks that, and the content blocks are the explanation:

json
{
  "jsonrpc": "2.0",
  "id": 12,
  "result": {
    "content": [
      {"type": "text",
       "text": "No invoice with id INV-88213 exists in the primary ledger. Ids in the INV-88xxx range issued before 2026-01-31 belong to subsidiary ledgers; use list_ledgers to find the right one, then fetch_invoice with the ledger argument. Do not retry this call unchanged."}
    ],
    "isError": true
  }
}

The second form travels into the conversation as a tool result the model reads, in the same place a successful result would have gone. The first form is handled by the client, and what the client does with it varies: some hosts synthesise a generic tool-failure message for the model, some surface the raw code and message, some show the user a red banner and put nothing at all in the model's context. From the server's side you cannot tell which, and the failure mode you have to survive is the last one.

Following that last case through is what turns the retry loop from an oddity into something predictable. If the client puts nothing into the context, then from the model's position the tool call produced no information whatsoever. Its own request is still there, the user's question is still there, and nothing anywhere in the conversation says anything about the invoice. The one thing it can infer is that something went wrong in the machinery, because a working tool returns something — and a transient machinery failure is a condition under which retrying is the correct response rather than a lapse in judgement. So the loop is the model reasoning correctly from the only evidence it was handed, and each further attempt is correct on the same grounds. That is why it does not converge: nothing in the sequence ever introduces a new fact.

Sequence: Model → Host + client (call fetch_invoice INV-88213); Host + client → Server (tools/call); Server → Host + client (JSON-RPC error -32603); Host + client → Model ((generic failure, or silence)); Model → Host + client (call fetch_invoice INV-88213).

Some SDK versions convert an uncaught exception into a result with isError: true and the exception's string as the content, which is the safer default and produces messages like KeyError: 'ledger_id' in the model's context. Others convert it into a JSON-RPC internal error. Grepping a server for raise inside tool handlers is a fast way to find every place the channel was chosen by accident, and it is worth doing on code you did not write.

Which failures belong in which channel

The dividing line is whether your tool logic ran and produced a verdict. A protocol error means the exchange itself failed: the method name was not recognised, the arguments did not satisfy the declared schema, the session was not initialised, the request was cancelled. In those cases there is no verdict to report, and the client is the right reader because the client is what has to recover. A tool error means the tool did its job and the answer is a failure — the record is missing, the caller is not permitted, the upstream refused, the state transition is illegal. Those are facts the model needs in order to choose its next action, and they only get there through the result channel.

SituationChannelWhat goes in it
Unknown tool nameJSON-RPC error -32601method not found
Arguments fail the declared schemaJSON-RPC error -32602which property, and why
Server not initialised, session goneJSON-RPC errortransport-level detail
Invoice id does not existresult, isError: truethe id is unknown, and how to find the right one
Caller lacks permissionresult, isError: truedenied, on which resource, who to ask
Upstream API returned 503result, isError: truetransient upstream failure, safe to retry once
Upstream call timed out, outcome unknownresult, isError: trueoutcome unknown; state it plainly
Bug in your handler: KeyErrorJSON-RPC error -32603generic message; details to your logs

The last row is the one people argue about. An unhandled exception in your own code is not a verdict about the caller's request, and reporting 'ledger_id' to a model produces guessing rather than recovery, so a protocol error plus an alert to your on-call rotation is the honest handling. What that row must not become is the catch-all that swallows the four rows above it, which is exactly what a blanket except Exception around the handler body does.

Argument validation sits on the line and is worth being deliberate about. Failures against the declared JSON Schema are the client's business, because a well-behaved client validates before sending and a violation means something upstream is broken. Failures against constraints you could not express in the schema — a date range wider than 90 days, an id that parses but belongs to a decommissioned region — are verdicts, and they belong in the result where the model can correct itself on the next turn.

The test that separates those two is worth stating as a question, because "sits on the line" is not something you can apply. Ask who is able to act on the message. A schema violation means the arguments never matched the contract that was published, and the only party in a position to fix that is whichever program built the arguments and failed to check them against the schema it was given — so the message is for a program, and the model cannot use it, because from the model's side it did produce something that looked right. A ninety-day window exceeded is different: the model chose that range, the model can choose a narrower one, and it will do so on the next turn if it is told. Same shape of failure, opposite reader, and the thing that decides is not how the failure was detected but who has the ability to do something about it.

Writing an error a model can act on

An error string in the result channel is prompt text with a different label, so it is read the way the description is read, and the same care applies. Three properties distinguish an error that ends the loop from one that feeds it: it states what is true about the world, it names the next action if there is one, and it says explicitly when repeating the call is pointless.

python
NOT_FOUND = (
    "No invoice with id {id} exists in the primary ledger. "
    "Ids in the INV-88xxx range issued before 2026-01-31 belong to subsidiary "
    "ledgers; call list_ledgers, then fetch_invoice with the ledger argument. "
    "Do not retry this call unchanged."
)


@server.call_tool()
async def call_tool(name: str, arguments: dict) -> CallToolResult:
    if name != "fetch_invoice":
        raise ValueError(f"unknown tool: {name}")        # -> JSON-RPC error

    invoice_id = arguments["invoice_id"]
    try:
        invoice = await ledger.fetch(invoice_id)
    except InvoiceNotFound:
        return error_result(NOT_FOUND.format(id=invoice_id))
    except LedgerUnavailable as exc:
        return error_result(
            f"The ledger service is unavailable ({exc.status}). This is transient. "
            f"Retry once after a short pause; if it fails again, tell the user "
            f"billing lookups are degraded and stop."
        )
    return ok_result(invoice.as_json())


def error_result(text: str) -> CallToolResult:
    return CallToolResult(content=[TextContent(type="text", text=text)], isError=True)

The wording carries more than the fact. "Invoice not found" is true and leaves the model with one hypothesis, which is that the lookup is flaky; naming the range, the cutoff date and the successor tool replaces that hypothesis with a plan. Length is not the variable that matters — the message above is 48 words and the useless one was two — but specificity is, and the specifics come from knowing which failures actually occur. The subsidiary-ledger clause exists because 71 of the 94 not-found lookups in a month's logs were ids in one range, so one clause covered three-quarters of the cases. Errors are worth writing from your own failure distribution rather than from the exception hierarchy.

That fraction is what earns the clause its words, and the division is worth doing. Seventy-one out of ninety-four is a little over three in four, so one sentence naming a single identifier range and a single cutoff date converts three not-found lookups in every four from a dead end into a next step. The remaining quarter still receives the generic half of the message and is no worse off than it was. The move that generalises is to sort your failures by how often each one actually happens and spend the specific guidance at the top of that list, because a message that is precise about a rare case and vague about the common one has put its length in the wrong place.

Two cautions on what to put in these strings. They enter the model's context and therefore the user's transcript, so a stack trace, an internal hostname, a SQL fragment or another tenant's identifier in an error message has been disclosed to whoever is watching, and to whatever the model does next with the text. And an error is an instruction the model is fairly likely to follow, which cuts both ways — a message ending "try again with force: true" will get exactly that, so the imperative in an error should be the action you want.

That second caution has a short derivation behind it, and following it changes how these strings get written. The content of a tool result is placed into the conversation as text. The model's entire job is to read the text in front of it and act. And nothing in the format distinguishes an imperative that came from your error string from an imperative that came from the user's own message — they are the same kind of thing in the same sequence. So a sentence in an error is not a note about what went wrong; it is a line in the instructions, competing on equal terms with everything else there. The practical consequence is that the verbs matter. "Do not retry this call unchanged" is doing work. "Try again later" is also doing work, and it is the work you will get.

Dashboards, status codes and ambiguous outcomes

The result channel is not a place to hide bugs from your own monitoring. A tool that returns isError: true looks, to most host telemetry, like a normal call, so a server that reports everything this way has a success rate of 100% on its dashboards while failing continuously. The counter you want increments on the verdict, not on the transport, and it is worth adding when you make this change.

Why the dashboard reports the wrong thing is worth spelling out, because the answer is not that it is broken. Host telemetry counts what it can see, and what it can see is the transport: a request went out, a response came back, and no error object was in it. That is a true description of a successful protocol exchange, and a result carrying isError: true is a successful protocol exchange — the failure lives in the payload, and a transport-level counter does not read payloads. So moving failures out of the protocol channel is not concealing them from monitoring; it is moving them somewhere the existing monitoring was never looking. The counter that fixes it has to increment where the verdict is decided, which is inside your handler and nowhere else.

This also is not the same distinction as HTTP status codes, although the analogy gets close. A 404 with a body explaining what was not found is the moral equivalent of isError: true, and a 502 from a gateway that never reached your application is the moral equivalent of a protocol error. Where the analogy fails is that an HTTP client and an HTTP server are the same two parties throughout, whereas here the reader of one channel is a program and the reader of the other is a model in a conversation. The question "who has to act on this" has an answer that changes per failure, and status-code habits do not encode it.

It is worth being explicit that the status codes are being used as an analogy rather than as a mapping, because the mapping is what people try to build next. Under HTTP the class of the code carries the whole decision, so a client can be written once to retry every 5xx and surface every 4xx without knowing what the service does. Nothing here works that way. There are only two channels rather than five classes, the second of them carries free text rather than a code, and the reader of that text is not the party that issued the request. Borrow the intuition that a failure reaching your logic is a different animal from a failure that never did, and stop there — the moment the analogy is pushed into a lookup table from exception type to channel, it has started deciding things it does not know about.

The choice of channel is therefore a question about readers: does anything downstream need to change its plan because of this, or does a program need to recover from it? A missing record, a refusal, a degraded upstream and an illegal state transition are all facts the conversation needs. An unrecognised method, a schema violation and a bug in your own code are facts a library needs. The test that separates them is whether your tool logic ran and produced a verdict, and the reason the default is so often wrong is that an exception answers a different question — it says something went wrong in the code, not who has to act on it.

Correcting the channel does not fix everything a retry loop reveals. The row in the table for a timed-out upstream call says to state plainly that the outcome is unknown, which is honest and is not sufficient: an invoice lookup is a read, so a repeat costs tokens, while the same ambiguity on a tool that creates a shipment or moves money means a retry can duplicate a side effect that already happened. Telling the model the truth about an ambiguous outcome does not make the second call safe. Making it safe is a property of the handler, and it is the property the idempotent hint claims your handler already has.

Tools over MCP — read the current version at https://daviddominguez.dev/learn/en/tools-over-mcp/protocol-errors-and-tool-errors

Back to top