-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Log MCPServer handler exceptions by kind and keep crash details off the wire #3314
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ff178c7
96b5cc8
6fd5e05
d3f3f5c
d0c72f2
f6f7627
fb7e316
6ad971d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,20 +1,20 @@ | ||||||
| # Handling errors | ||||||
|
|
||||||
| A tool can fail in two ways, and the SDK treats them very differently. | ||||||
| A tool can fail in three ways, and the SDK treats each differently. | ||||||
|
|
||||||
| Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the **protocol** sees it. | ||||||
| Raise `ToolError` and the **model** sees your message. Raise `MCPError` and the **protocol** sees it. Raise anything else and it is a crash: the model learns only that the call failed, and your log gets the traceback. | ||||||
|
|
||||||
| This page is about choosing. | ||||||
|
|
||||||
| ## An error the model can fix | ||||||
|
|
||||||
| Take a tool that looks something up, and let the lookup miss: | ||||||
|
|
||||||
| ```python title="server.py" hl_lines="11-12" | ||||||
| ```python title="server.py" hl_lines="2 12-13" | ||||||
| --8<-- "docs_src/handling_errors/tutorial001.py" | ||||||
| ``` | ||||||
|
|
||||||
| There is nothing MCP about those two lines. `get_author` raises a plain `ValueError`, the way any Python function would. | ||||||
| `ToolError`, from `mcp.server.mcpserver.exceptions`, is how a tool tells the model that something went wrong. | ||||||
|
|
||||||
| Call it with a title that isn't in the catalog and look at the result: | ||||||
|
|
||||||
|
|
@@ -25,21 +25,23 @@ result.structured_content # None | |||||
| ``` | ||||||
|
|
||||||
| * The request **succeeded**. There is a result; nothing was raised at the caller. | ||||||
| * `is_error` is `True`, and your exception's message (prefixed with the tool name) is in `content`, exactly where the model reads. | ||||||
| * `is_error` is `True`, and your message (prefixed with the tool name) is in `content`, exactly where the model reads. | ||||||
| * `structured_content` is `None`. A failed call has no return value to structure. | ||||||
|
|
||||||
| This is a **tool error**, and it is the default for *any* exception your tool raises. It is also almost always what you want. | ||||||
| This is a **tool error**, and it is almost always what you want. | ||||||
|
|
||||||
| The model is the one calling your tool. It picked the arguments. So a tool error is a turn in the conversation: the model reads *"No book titled 'Nothing' in the catalog."*, realises it guessed the title wrong, and calls again with a better one. You wrote one `raise` and got a self-correcting agent. | ||||||
|
|
||||||
| On the server, a `ToolError` is one `INFO` line in the log, with no traceback. You saw it coming, so there is nothing to investigate. | ||||||
|
|
||||||
| !!! tip | ||||||
| Never `return` an error message from a tool. A returned string has `is_error=False`, so to the | ||||||
| model (and to every client UI) it looks like the tool worked and that string was the answer. | ||||||
| `raise`. The flag is the signal. | ||||||
|
|
||||||
| ## An error the model cannot fix | ||||||
|
|
||||||
| Now swap `ValueError` for `MCPError`. | ||||||
| Now swap `ToolError` for `MCPError`. | ||||||
|
|
||||||
| ```python title="server.py" hl_lines="1 3 14" | ||||||
| --8<-- "docs_src/handling_errors/tutorial002.py" | ||||||
|
|
@@ -72,10 +74,10 @@ Now swap `ValueError` for `MCPError`. | |||||
|
|
||||||
| The two paths answer two different questions. | ||||||
|
|
||||||
| * **Raise any exception** for a failure of *execution*: the thing your tool tried to do didn't work. The model chose the call, so the model should see the consequence and get a chance to recover. A misspelled title, an upstream API that timed out, a row that doesn't exist: all tool errors. | ||||||
| * **Raise `ToolError`** for a failure of *execution*: the thing your tool tried to do didn't work. The model chose the call, so the model should see the consequence and get a chance to recover. A misspelled title, an upstream API that timed out, a row that doesn't exist: all tool errors. | ||||||
| * **Raise `MCPError`** when the *request itself* should be rejected: the client is missing a capability your tool depends on, the server isn't in a state to serve anyone, the caller skipped a required step. No retry from the model fixes any of those, so there is nothing to gain from handing it the message. | ||||||
|
|
||||||
| One question decides it: **could a smarter model have avoided this?** Yes -> ordinary exception. No -> `MCPError`. | ||||||
| One question decides it: **could a smarter model have avoided this?** Yes -> `ToolError`. No -> `MCPError`. | ||||||
|
|
||||||
| By that test, the second version of `get_author` made the wrong choice: a better title fixes it, so the model deserved to see the message. It's there to show you the mechanism, not to recommend it. | ||||||
|
|
||||||
|
|
@@ -84,6 +86,25 @@ By that test, the second version of `get_author` made the wrong choice: a better | |||||
| `data` payload. Whatever you put in them is what the client receives: the SDK forwards a raised | ||||||
| `MCPError` verbatim instead of sanitising it. | ||||||
|
|
||||||
| ## Any other exception | ||||||
|
|
||||||
| Now take the check out and let the dictionary lookup fail on its own: | ||||||
|
|
||||||
| ```python title="server.py" hl_lines="11" | ||||||
| --8<-- "docs_src/handling_errors/tutorial004.py" | ||||||
| ``` | ||||||
|
|
||||||
| `CATALOG[title]` raises `KeyError`. You didn't plan for it, so the SDK treats it as a crash: | ||||||
|
|
||||||
| ```python | ||||||
| result.is_error # True | ||||||
| result.content # [TextContent(text="Error executing tool get_author")] | ||||||
| ``` | ||||||
|
|
||||||
| The call still returns `is_error=True`, so the model knows it failed and can move on. What it doesn't get is the exception's text: a `KeyError` from your code, or a stack of SQL from a driver three libraries down, may describe your server's internals, so it never leaves the server. | ||||||
|
|
||||||
| You get it instead. The server logs the crash at `ERROR` with the full traceback, as `Tool 'get_author' raised an unexpected exception`. A production log at `WARNING` therefore stays quiet through every `ToolError` and speaks up the moment something is actually broken. | ||||||
|
|
||||||
| ## A resource that doesn't exist | ||||||
|
|
||||||
| Resources draw the same line, and ship one named exception for the common case. | ||||||
|
|
@@ -104,7 +125,7 @@ When it can't, raise `ResourceNotFoundError`. The SDK turns it into the protocol | |||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| Notice there is no `is_error=True` half-result here. A resource read either returns contents or fails: resources have only the protocol path. Templates and everything else about resources live in **[Resources](resources.md)**. | ||||||
| Notice there is no `is_error=True` half-result here. A resource read either returns contents or fails: resources have only the protocol path. `ResourceError` is the same thing for a failure that isn't "not found" (`-32603`, your message). Any other exception is a crash: the client gets `-32603` naming only the URI, and the traceback goes to your log at `ERROR`. Templates and everything else about resources live in **[Resources](resources.md)**. | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The resource section omits the (Based on your team's feedback about documenting resource error outcomes.) Prompt for AI agents-Notice there is no Errors you never raise</file context> |
||||||
|
|
||||||
| ## Errors you never raise | ||||||
|
|
||||||
|
|
@@ -116,18 +137,20 @@ It means a whole class of `raise` statements you don't write: don't re-validate | |||||
|
|
||||||
| !!! info | ||||||
| Everything on this page is what a **client** sees, and the in-memory `Client` you'll write | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This info box now says every statement on the page is client-visible, but the page also documents server-only Prompt for AI agents
Suggested change
|
||||||
| tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error | ||||||
| back into a traceback: by the time that flag could act, your exception is already the | ||||||
| `is_error=True` result. Assert on the result. **[Testing](../get-started/testing.md)** covers the pattern. | ||||||
| tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't hand a failing | ||||||
| tool's exception back to the caller: by the time that flag could act, your exception is already | ||||||
| the `is_error=True` result. Assert on the result. If you need the traceback of a crash, it is in | ||||||
| the server's log, and pytest's `caplog` captures it. **[Testing](../get-started/testing.md)** covers the pattern. | ||||||
|
|
||||||
| ## Recap | ||||||
|
|
||||||
| * Raise **any exception** in a tool -> the call returns `is_error=True` with your message in `content`. The model reads it and can retry. This is the default. | ||||||
| * Raise **`ToolError`** in a tool -> the call returns `is_error=True` with your message in `content`. The model reads it and can retry. | ||||||
| * Raise **`MCPError`** -> the call itself fails with a JSON-RPC error. The model sees nothing; the host deals with it. `code`, `message`, and `data` survive intact. | ||||||
| * The deciding question: *could a smarter model have avoided this?* Yes -> exception. No -> `MCPError`. | ||||||
| * The deciding question: *could a smarter model have avoided this?* Yes -> `ToolError`. No -> `MCPError`. | ||||||
| * Any **other exception** is a crash -> `is_error=True` with only `Error executing tool <name>` for the model, and an `ERROR` record with the traceback for you. | ||||||
| * `ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`. | ||||||
| * Bad arguments are rejected against the schema before your function runs; you don't `raise` for those. | ||||||
| * `from mcp import MCPError`; the error-code constants come from `mcp.types`. | ||||||
| * Imports: `from mcp import MCPError`, `from mcp.server.mcpserver.exceptions import ToolError, ResourceNotFoundError`, and the error-code constants from `mcp.types`. | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The page now introduces Prompt for AI agents
Suggested change
|
||||||
|
|
||||||
| Errors handled. That is everything a server *exposes*. What every handler can read, and do back to the client while it runs, is the next section: **[Inside your handler](../handlers/index.md)**. | ||||||
|
|
||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 nit: The quoted server-log line
mcp.MCPDeprecationWarning: The logging capability is deprecated...is not what the log will contain — a Python traceback renders the exception's real__module__.__qualname__, and MCPDeprecationWarning is defined inmcp.shared.exceptionswith no__module__override, so the captured log's final line readsmcp.shared.exceptions.MCPDeprecationWarning: ....Extended reasoning...
A user follows the tip, runs pytest with
error::mcp.MCPDeprecationWarning, and greps the captured server log for the documented stringmcp.MCPDeprecationWarning:(or asserts on it in a test) — it never matches, because the ERROR record's traceback ends withmcp.shared.exceptions.MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).; the accompanying test (tests/docs_src/test_deprecated.py) only checkstype(...).__name__andstr(...), so the doc drift is not caught.Verification: nit. docs/deprecated.md:123-126 (added by this diff) presents the block as literal captured server-log content — "the captured server log names the culprit:" followed by
mcp.MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).— but that string never appears in the log.MCPDeprecationWarningis defined at src/mcp/shared/exceptions.py:8 with no__module__