
This lesson is part of Learn MCP, our free course on the Model Context Protocol, within The MCP Guide.
In this lesson you will learn the two kinds of MCP errors and when to use each. You will raise a fixable error, a request-level error and an unexpected crash in Python. Then you will compare TypeScript and rewrite error messages so a model can recover.
Good errors matter more in MCP than in a normal API. The caller is often an AI model, and the error message is the only clue it gets. A clear message lets it fix its own mistake. A vague one makes it give up or guess.
The code adapts the Python error-handling guide and the TypeScript Errors guide. It ran on 28 October 2026 with mcp 2.3.0 and @modelcontextprotocol/server 2.4.0.
Key Takeaways
- The spec defines two error paths: tool execution errors (
isError: true, which the model reads) and protocol errors (JSON-RPC errors, which the host handles). - Use a tool error when a smarter call could succeed; use a protocol error when no retry from the model can fix it.
- Python hides the text of unexpected exceptions from the model; TypeScript sends a thrown error's message to the model.
- Resource and prompt failures are always protocol errors, because no model reads them.
- Put the fix in the message: what was wrong, what is valid, and what to call next.
Step 1: Learn the two paths from the spec
The tools spec says tools use two error reporting mechanisms:
| Tool execution error | Protocol error | |
|---|---|---|
| What it is | A normal result with isError: true | A JSON-RPC error response |
| Who reads it | The model | The host application |
| Spec examples | API failures, input validation errors, business logic errors | Unknown tool, malformed request, server errors |
| Can the model retry? | Yes, that is the point | Rarely |
The spec adds that clients should provide tool execution errors to language models so they can self-correct. Clients may pass protocol errors along, but those "are less likely to result in successful recovery."
The Python guide reduces the choice to one question: could a smarter model have avoided this? If yes, use a tool error. If no, use a protocol error.
Step 2: Raise the right error in Python
from mcp import MCPError
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import INVALID_REQUEST
mcp = MCPServer("Bookshop")
CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}
MAINTENANCE = False
@mcp.tool()
def get_author(title: str) -> str:
"""Look up the author of a book. Use an exact title."""
if MAINTENANCE:
# Nothing the model can do about this: reject the request itself.
raise MCPError(code=INVALID_REQUEST, message="Catalog is in maintenance. Try again later.")
if title not in CATALOG:
# The model can fix this: tell it how.
known = ", ".join(sorted(CATALOG))
raise ToolError(f"No book titled {title!r}. Known titles: {known}.")
return CATALOG[title]
@mcp.tool()
def get_author_unsafe(title: str) -> str:
"""Same lookup, with no check (shows what a crash looks like)."""
return CATALOG[title]
Calling each from an in-memory test client gave this on 28 October 2026:
ToolError -> True Error executing tool get_author: No book titled 'Dun'. Known titles: Dune, Neuromancer.
KeyError -> True Error executing tool get_author_unsafe
MCPError -> -32600 Catalog is in maintenance. Try again later.
What happened, matching the Python guide:
ToolErrorreturned a result withis_error=Trueand your message. The model can read "Known titles" and retry.- An unexpected
KeyErroralso returnedis_error=True, but only the text "Error executing tool get_author_unsafe." The guide explains that exception text "may describe your server's internals, so it never leaves the server." The full traceback went to the server log atERRORlevel. MCPErrorwas not caught. The whole request failed with a JSON-RPC error, and the client raised an exception. The model saw nothing.
One more rule from the guide: never return an error message as a normal string. A returned string has is_error=False, so the model and the host both think the tool worked.
Step 3: Compare TypeScript
The TypeScript SDK draws the line differently, so read this section even if you only write Python.
server.registerTool(
'get_author',
{ description: 'Look up the author of a book. Use an exact title.', inputSchema: z.object({ title: z.string() }) },
async ({ title }) => {
const author = CATALOG[title];
if (!author) {
return {
content: [{ type: 'text', text: `No book titled "${title}". Known titles: ${Object.keys(CATALOG).join(', ')}.` }],
isError: true
};
}
return { content: [{ type: 'text', text: author }] };
}
);
Three variants ran against an in-process client:
| Handler did this | Result the client got |
|---|---|
Returned isError: true with a message | isError: true, your message |
Threw new Error('No book titled "Dun"') | isError: true, text "No book titled \"Dun\"" |
Threw new ProtocolError(InvalidRequest, 'Catalog is in maintenance') | isError: true, text "Catalog is in maintenance" |
The TypeScript guide confirms this: a tool handler produces only tool errors. The SDK converts every exception it throws, including a ProtocolError, into an isError: true result. The one documented exception is UrlElicitationRequiredError.
Two practical consequences:
- In TypeScript, a thrown error's message reaches the model. Do not throw errors that contain SQL, file paths, tokens or stack details. Catch them and return a clean message.
- In TypeScript, a tool cannot fail the request with a protocol error. If you need that, reject the request before it reaches the tool. In resources and prompts, the guide says to throw
ProtocolError.
Step 4: Handle resource and prompt failures
Resources and prompts are read by the application, not chosen by the model, so they have no isError channel. Both SDKs use protocol errors there:
- Python: raise
ResourceNotFoundError. Lesson 16 showed it produced-32602with the URI indata. - TypeScript: throw
ResourceNotFoundError(uri)orProtocolError(code, message).
The resources spec requires -32602 for a resource that does not exist, and says servers must not return an empty contents array instead.
Step 5: Write messages a model can use
A good tool error answers three questions: what went wrong, what is valid, and what to do next. Compare:
| Weak message | Better message |
|---|---|
| "Not found" | "No book titled 'Dun'. Known titles: Dune, Neuromancer." |
| "Invalid date" | "Invalid departure date: must be in the future. Current date is 08/08/2025." (the spec's own example) |
| "Error 500" | "The inventory service timed out. Try again in a minute, or call get_stock for one title." |
| "Bad input" | "order_id must look like ORD-004211. Call list_orders to find the ID." |
Some habits:
- Name the next tool. Models follow concrete suggestions well.
- Include valid values when the list is short. For long lists, point to the tool that lists them.
- Keep secrets out. No tokens, internal hostnames or customer data from other accounts.
- Do not re-validate types. Both SDKs reject bad arguments against the schema before your code runs. Spend your effort on business rules instead.
Step 6: Log for yourself, message for the model
The model's message and your log serve different readers. Python already splits them: ToolError logs one INFO line, and a crash logs a full traceback at ERROR. The guide notes that a production log at WARNING stays quiet through tool errors and speaks up when something actually breaks.
In TypeScript, log the real error with console.error() before returning a clean message. On stdio, console.log() would corrupt the protocol stream.
Try it
- Add an
order_booktool that fails when stock is zero. Make it a tool error that names a tool for checking restock dates. - Add a check that rejects orders while
MAINTENANCEis true. Choose the error type deliberately in Python, and note how TypeScript would differ. - Make
get_author_unsafecall a function that raises an exception with a fake database password in its message. Confirm Python hides it from the client. - Do the same in TypeScript. You will see the password in the result. Fix it.
Recap
- Tool errors (
isError: true) are for the model; protocol errors are for the host. - Python:
ToolErrorfor fixable problems,MCPErrorto reject the request, and crashes are hidden from the model. - TypeScript: every thrown error in a tool becomes
isError: truewith its message, so sanitize. - Resources and prompts fail with protocol errors such as
-32602.
Next step
Lesson 21 takes your server off your laptop. For the spec's view of errors, read how MCP servers report tool errors and MCP JSON-RPC messages. The Learn MCP course page lists every lesson.
Course navigation
Previous lesson: Lesson 19: Test With the MCP Inspector.
Next lesson: Lesson 21: Deploy a Remote Server.
Back to the Learn MCP course outline.
Frequently asked questions
Is a validation error a tool error or a protocol error?
For tool arguments, it is a tool error. The spec lists input validation errors under tool execution errors, and both SDKs return them as isError: true so the model can retry. For prompt arguments, it is a protocol error.
What error code should I use for a protocol error?
Use the standard JSON-RPC codes. -32602 is invalid params and -32603 is internal error. The Python SDK exports these as constants in mcp.types, and the TypeScript SDK has ProtocolErrorCode.
Should I retry failed upstream calls inside the tool?
Often, yes, for short transient failures. Keep retries brief so the tool call does not hang. If retries fail, return a tool error that tells the model what happened and whether trying later may help.
Why did my Python tool's exception text disappear?
Because unexpected exceptions are treated as crashes. The SDK shows the model only "Error executing tool <name>" and logs the full details on the server. Raise ToolError with a message when you want the model to see it.
Do hosts show tool errors to the user?
It depends on the host. Many show the tool call and its result. Write every error message as if a customer might read it.











