Log MCPServer handler exceptions by kind and keep crash details off the wire (#3314)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
This commit is contained in:
parent
f1c40b062f
commit
56af44727b
3 changed files with 1427 additions and 310 deletions
|
|
@ -1,8 +1,8 @@
|
|||
# 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.
|
||||
|
||||
|
|
@ -10,11 +10,11 @@ This page is about choosing.
|
|||
|
||||
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,13 +25,15 @@ 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.
|
||||
|
|
@ -39,7 +41,7 @@ The model is the one calling your tool. It picked the arguments. So a tool error
|
|||
|
||||
## 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), and both are one `INFO` line in your log. Any other exception bar `MCPError` 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)**.
|
||||
|
||||
## Errors you never raise
|
||||
|
||||
|
|
@ -115,19 +136,21 @@ Send `get_author` a `title` that isn't a string and the SDK rejects it against t
|
|||
It means a whole class of `raise` statements you don't write: don't re-validate your own type hints.
|
||||
|
||||
!!! info
|
||||
Everything on this page is what a **client** sees, and the in-memory `Client` you'll write
|
||||
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.
|
||||
Everything a **client** sees on this page, the in-memory `Client` you'll write tests with
|
||||
sees too. 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, ResourceError, ResourceNotFoundError`, and the error-code constants from `mcp.types`.
|
||||
|
||||
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)**.
|
||||
|
||||
|
|
|
|||
|
|
@ -182,18 +182,19 @@ You don't notice while you build the value by hand: Pydantic already made sure y
|
|||
The annotation promises `WeatherData`. The upstream response stopped sending `humidity`.
|
||||
|
||||
!!! check
|
||||
Call `get_weather` and it does not quietly hand the client a half-empty object. The call fails,
|
||||
and the first lines of the error name the field:
|
||||
Call `get_weather` and it does not quietly hand the client a half-empty object. The call fails:
|
||||
the client gets `is_error=True` with `Error executing tool get_weather`, so the model knows the
|
||||
call failed instead of confidently reading weather that isn't there. The field name is for you,
|
||||
in the server log at `ERROR`:
|
||||
|
||||
```text
|
||||
Error executing tool get_weather: 1 validation error for WeatherData
|
||||
Tool 'get_weather' raised an unexpected exception
|
||||
...
|
||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for WeatherData
|
||||
humidity
|
||||
Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict]
|
||||
```
|
||||
|
||||
That text comes back as the tool result with `is_error=True`, so the model knows the call failed
|
||||
instead of confidently reading weather that isn't there.
|
||||
|
||||
Returning a plain `dict` from a `-> WeatherData` tool is fine, by the way. That's exactly what `json.loads` produced. Validation is on the value, not on the Python type.
|
||||
|
||||
## Opting out
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ The built-in checks stop the common cases but can't know your sandbox
|
|||
boundary. For filesystem access, use `safe_join` to resolve the path
|
||||
and verify it stays inside your base directory:
|
||||
|
||||
```python title="server.py" hl_lines="4 14"
|
||||
```python title="server.py" hl_lines="5 15"
|
||||
--8<-- "docs_src/uri_templates/tutorial002.py"
|
||||
```
|
||||
|
||||
|
|
@ -199,10 +199,10 @@ These checks are a heuristic pre-filter; for filesystem access,
|
|||
`safe_join` remains the containment boundary.
|
||||
|
||||
!!! tip
|
||||
If your handler can't fulfil the request (the file doesn't exist,
|
||||
the id is unknown), raise an exception. The SDK turns it into an
|
||||
error response. See **[Handling errors](handling-errors.md)** for the difference between a
|
||||
protocol error and a tool error.
|
||||
If your handler can't fulfil the request (the file doesn't exist, the id is unknown), raise
|
||||
`ResourceNotFoundError` as `read_manual` does above. The client gets `-32602` with your message
|
||||
and the URI. An unexpected exception becomes a generic `-32603` instead. See
|
||||
**[Handling errors](handling-errors.md#a-resource-that-doesnt-exist)**.
|
||||
|
||||
## Resources on the low-level Server
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue