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
45 changed files with 1427 additions and 310 deletions
|
|
@ -81,7 +81,7 @@ That schema is everything a UI needs to render an argument form, and everything
|
|||
|
||||
`call_tool(name, arguments)` runs the tool and gives you back a `CallToolResult`.
|
||||
|
||||
```python title="client.py" hl_lines="26-33"
|
||||
```python title="client.py" hl_lines="27-34"
|
||||
--8<-- "docs_src/client/tutorial003.py"
|
||||
```
|
||||
|
||||
|
|
@ -113,7 +113,7 @@ A tool that raises does **not** raise in your client. It comes back as an ordina
|
|||
|
||||
!!! check
|
||||
Ask `lookup_book` for `"Solaris"` (a title that isn't in the catalog) and the function raises
|
||||
`ValueError`. The call still returns normally:
|
||||
`ToolError`. The call still returns normally:
|
||||
|
||||
```python
|
||||
result.is_error # True
|
||||
|
|
@ -121,9 +121,10 @@ A tool that raises does **not** raise in your client. It comes back as an ordina
|
|||
result.structured_content # None
|
||||
```
|
||||
|
||||
The exception's message landed in `content`, where the **model** can read it and try again. That
|
||||
is deliberate: a tool error is part of the conversation, not a crash. Always look at `is_error`
|
||||
before you trust `structured_content`.
|
||||
The `ToolError`'s message landed in `content`, where the **model** can read it and try again. That
|
||||
is deliberate: a tool error is part of the conversation, not a crash. (Had the tool crashed with
|
||||
some other exception, `content` would say only `Error executing tool lookup_book`.) Always look at
|
||||
`is_error` before you trust `structured_content`.
|
||||
|
||||
!!! warning
|
||||
`is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Deprecated features
|
||||
|
||||
The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**.
|
||||
The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. One SDK helper is deprecated on its own account and is listed [at the end](#deprecated-sdk-helpers).
|
||||
|
||||
The table below names each deprecated feature, why it is going away, and the replacement to build on.
|
||||
|
||||
|
|
@ -119,15 +119,24 @@ That is the whole API. There is no per-method switch, and you don't want one: th
|
|||
Run the filter the other way and you get a free regression test. Add
|
||||
`"error::mcp.MCPDeprecationWarning"` to the `filterwarnings` setting in your pytest
|
||||
configuration and the deprecated call **raises** instead of warning. A tool named
|
||||
`old_log` that still calls `ctx.info()` stops passing and starts reporting:
|
||||
`old_log` that still calls `ctx.info()` stops passing: the call comes back `is_error=True` with
|
||||
`Error executing tool old_log`, and the captured server log names the culprit:
|
||||
|
||||
```text
|
||||
Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
|
||||
mcp.shared.exceptions.MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
|
||||
```
|
||||
|
||||
One line of pytest configuration, and a deprecated call can never sneak back into your
|
||||
codebase without failing a test.
|
||||
|
||||
## Deprecated SDK helpers
|
||||
|
||||
These are not spec changes, only SDK internals with a better replacement. They warn with the same `MCPDeprecationWarning` and will be removed in 3.0.
|
||||
|
||||
| Deprecated | What you do instead |
|
||||
|---|---|
|
||||
| `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` and then `FuncMetadata.call_fn()`. Only code that drives `FuncMetadata` directly (a custom `Tool` subclass, say) ever called it. |
|
||||
|
||||
## Recap
|
||||
|
||||
* The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), restricts **progress** to server-to-client, and removes **`ping`**.
|
||||
|
|
@ -135,6 +144,7 @@ That is the whole API. There is no per-method switch, and you don't want one: th
|
|||
* Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default).
|
||||
* Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise.
|
||||
* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure.
|
||||
* One SDK helper, `FuncMetadata.call_fn_with_arg_validation()`, is deprecated separately for removal in 3.0.
|
||||
* New code should not be built on any of these.
|
||||
|
||||
Every other page in these docs teaches the current API.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Which means connecting to a host is one act: you tell it **the command that star
|
|||
|
||||
## One server, every host
|
||||
|
||||
```python title="server.py" hl_lines="3 33-34"
|
||||
```python title="server.py" hl_lines="4 34-35"
|
||||
--8<-- "docs_src/real_host/tutorial001.py"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -79,8 +79,8 @@ There you go! You can now extend your tests to cover more scenarios.
|
|||
Two different things can go wrong, and this flag only touches one of them.
|
||||
|
||||
An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with
|
||||
`is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or
|
||||
without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it:
|
||||
`is_error=True` (and if it was a `ToolError`, the model reads your message). `raise_exceptions` doesn't
|
||||
change that: with or without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it:
|
||||
**[Handling errors](../servers/handling-errors.md)**.
|
||||
|
||||
A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ That schema is the form. `Field(description=...)` is the label; a default pre-fi
|
|||
!!! warning
|
||||
An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields
|
||||
only: `str`, `int`, `float`, `bool`, or a `Literal` of strings (it becomes an `enum`).
|
||||
Put a model inside the model and `ctx.elicit` raises before anything is sent to the client:
|
||||
Put a model inside the model and `ctx.elicit` raises before anything is sent to the client.
|
||||
The tool call fails with `Error executing tool <name>`, and your server log has the reason:
|
||||
|
||||
```text
|
||||
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
|
||||
|
|
@ -107,8 +108,8 @@ A refusal is not an error. The tool decides what declining means (here, no booki
|
|||
|
||||
!!! tip
|
||||
The answer is validated against your model before your code sees it. A client that sends
|
||||
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a
|
||||
schema-mismatch error, your `if` never runs.
|
||||
`"maybe"` for a `bool` doesn't corrupt your booking: `ctx.elicit` raises `ValueError`, the call
|
||||
fails, and your `if` never runs.
|
||||
|
||||
## Send the user to a URL
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ The default is `"INFO"`.
|
|||
|
||||
`logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins.
|
||||
|
||||
You also don't need a `try`/`except` in every handler just to record failures. When a tool or resource function raises, the SDK logs it for you. **[Handling errors](../servers/handling-errors.md#any-other-exception)** explains what gets logged and at which level.
|
||||
|
||||
## Try it
|
||||
|
||||
Run the server with the MCP Inspector:
|
||||
|
|
|
|||
|
|
@ -992,8 +992,8 @@ its behavior is unchanged.
|
|||
`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it
|
||||
when the request itself should be rejected (missing client capability,
|
||||
elicitation required, invalid parameters). For tool *execution* failures the
|
||||
calling LLM should see and react to, raise any other exception or return
|
||||
`CallToolResult(is_error=True, ...)` directly; that path is unchanged.
|
||||
calling LLM should see and react to, raise `ToolError` or return
|
||||
`CallToolResult(is_error=True, ...)` directly.
|
||||
|
||||
The client sees this change too. `Client.call_tool()` and
|
||||
`ClientSession.call_tool()` raise on a JSON-RPC error response, so a tool that
|
||||
|
|
@ -1016,7 +1016,7 @@ except MCPError as e:
|
|||
|
||||
### Resource not found returns `-32602` and resource lookups raise typed exceptions (SEP-2164)
|
||||
|
||||
Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a template handler that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response.
|
||||
Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a resource handler (static or template) that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response.
|
||||
|
||||
The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`).
|
||||
|
||||
|
|
@ -2737,7 +2737,7 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve
|
|||
|
||||
Every deprecation below is a runtime warning as well as a type-checker one: deprecated methods and helpers emit `mcp.MCPDeprecationWarning` on each call, and the deprecated `Server(...)` constructor parameters (`on_set_logging_level`, `on_roots_list_changed`, `on_progress`) emit it at construction time. The category subclasses `UserWarning`, not `DeprecationWarning`, so it is visible by default; [Deprecated features](deprecated.md) has the full list and each replacement.
|
||||
|
||||
Under pytest's `filterwarnings = ["error"]`, that warning becomes an exception at the first deprecated call. Inside an `@mcp.tool()` handler the exception is caught like any other and returned as `CallToolResult(is_error=True)` (`Error executing tool ...: The logging capability is deprecated as of 2026-07-28 (SEP-2577).`), which reads as a failing tool rather than a warning. Keep the warnings visible but non-fatal with:
|
||||
Under pytest's `filterwarnings = ["error"]`, that warning becomes an exception at the first deprecated call. Inside an `@mcp.tool()` handler the exception is caught like any other and returned as `CallToolResult(is_error=True)` (`Error executing tool ...`, with the `MCPDeprecationWarning` traceback in the server log), which reads as a failing tool rather than a warning. Keep the warnings visible but non-fatal with:
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -76,11 +76,11 @@ async def main() -> None:
|
|||
|
||||
`__aexit__` is the disconnection, which is why there is no `client.close()` to forget. **[Testing](get-started/testing.md)** is built on exactly this pattern.
|
||||
|
||||
## `Error executing tool <name>: <message>` and `Unknown tool: <name>`
|
||||
## `Error executing tool <name>: <message>`, `Error executing tool <name>`, and `Unknown tool: <name>`
|
||||
|
||||
You are reading a **result**, not an exception. `call_tool` did not raise, and it never will for a failing tool.
|
||||
|
||||
Call `forecast` for a city the server doesn't know, and the exception it raises comes back with the request marked as *succeeded*:
|
||||
Call `forecast` for a city the server doesn't know, and the `ToolError` it raises comes back with the request marked as *succeeded*:
|
||||
|
||||
```python
|
||||
result.is_error # True
|
||||
|
|
@ -92,6 +92,8 @@ result.structured_content # None
|
|||
|
||||
The fix is in your client: **check `result.is_error`**. A `try/except` around `call_tool` catches none of these, because there is nothing to catch. This is deliberate, and it is the single most useful thing on this page to internalise: the *model* chose the call, so the model gets the message and a chance to try again. **[Handling errors](servers/handling-errors.md)** is the whole story, including the `MCPError` path that *does* raise.
|
||||
|
||||
The bare form, `Error executing tool <name>` with no message, means the tool **crashed**: an exception it didn't anticipate escaped it (or its return value failed the output schema), and that exception's text is kept off the wire. The traceback is in the **server's log** at `ERROR`, as `Tool '<name>' raised an unexpected exception`.
|
||||
|
||||
## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool`
|
||||
|
||||
You wrote `@mcp.tool` instead of `@mcp.tool()`. `tool()` is a decorator *factory*: without the parentheses, Python hands your function to its `name=` parameter.
|
||||
|
|
@ -404,7 +406,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
|
|||
## Recap
|
||||
|
||||
* `ExceptionGroup: unhandled errors in a TaskGroup` is never the error. Read the **last line**; catching `MCPError` *inside* the `async with Client(...)` block skips the wrapping entirely.
|
||||
* `call_tool` does not raise for a failing tool. `Error executing tool ...` and `Unknown tool: ...` are results: check `result.is_error`.
|
||||
* `call_tool` does not raise for a failing tool. `Error executing tool ...` and `Unknown tool: ...` are results: check `result.is_error`. No message after the tool name means it crashed, and the traceback is in the server log.
|
||||
* `Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> add the parentheses.
|
||||
* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.
|
||||
* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: <host>` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`.
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ On those types, every Python attribute is now snake_case: `result.is_error`, `to
|
|||
The renames announce themselves. These do not:
|
||||
|
||||
* **Sync functions run on a worker thread.** A `def` tool (or resource, prompt, or resolver) no longer blocks the event loop; the trade is that its body no longer runs *on* the event-loop thread, which matters to thread-affine code. `async def` handlers are untouched. **[Migration Guide](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**.
|
||||
* **`MCPError` (v1's `McpError`) raised inside a tool is a protocol error now.** The model never sees it. Every other exception still becomes an `is_error=True` result the model can read and react to. **[Handling errors](servers/handling-errors.md)** is the split.
|
||||
* **`MCPError` (v1's `McpError`) raised inside a tool is a protocol error now.** The model never sees it. Every other exception still becomes an `is_error=True` result, but only a `ToolError`'s message reaches the model: any other exception now reads `Error executing tool <name>`, with the traceback in your server log. **[Handling errors](servers/handling-errors.md)** is the split.
|
||||
* **Results are validated before they leave.** A hand-built `Tool` whose `input_schema` is `{}` now fails `tools/list` (the spec requires `"type": "object"`). Servers built on `@mcp.tool()` never see this; the SDK writes their schemas.
|
||||
* **Your client validates what it receives.** `list_tools()` and `call_tool()` check the server's answer against the negotiated protocol version, so a not-quite-valid server that v1's lenient parse tolerated now raises `pydantic.ValidationError`. If you connect to servers you do not control, expect to be the one who finds them; the **[Migration Guide](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)** has the details.
|
||||
* **URI templates are real RFC 6570 now.** `{+path}`, `{?query}` and friends work, matching is exact instead of regex-loose, and path traversal in extracted values is rejected by default. Stricter templates fail at decoration time, not on the first request. **[URI templates](servers/uri-templates.md)**.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from pydantic import BaseModel
|
|||
|
||||
from mcp import Client
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
from mcp.types import TextContent
|
||||
|
||||
mcp = MCPServer("Bookshop")
|
||||
|
|
@ -17,7 +18,7 @@ class Book(BaseModel):
|
|||
def lookup_book(title: str) -> Book:
|
||||
"""Look up a book by its exact title."""
|
||||
if title != "Dune":
|
||||
raise ValueError(f"No book titled {title!r} in the catalog.")
|
||||
raise ToolError(f"No book titled {title!r} in the catalog.")
|
||||
return Book(title="Dune", author="Frank Herbert", year=1965)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
|
||||
mcp = MCPServer("Bookshop")
|
||||
|
||||
|
|
@ -9,5 +10,5 @@ CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}
|
|||
def get_author(title: str) -> str:
|
||||
"""Look up the author of a book in the catalog."""
|
||||
if title not in CATALOG:
|
||||
raise ValueError(f"No book titled {title!r} in the catalog.")
|
||||
raise ToolError(f"No book titled {title!r} in the catalog.")
|
||||
return CATALOG[title]
|
||||
|
|
|
|||
11
docs_src/handling_errors/tutorial004.py
Normal file
11
docs_src/handling_errors/tutorial004.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from mcp.server import MCPServer
|
||||
|
||||
mcp = MCPServer("Bookshop")
|
||||
|
||||
CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_author(title: str) -> str:
|
||||
"""Look up the author of a book in the catalog."""
|
||||
return CATALOG[title]
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
|
||||
mcp = MCPServer("Bookshop")
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ def search_books(query: str) -> list[str]:
|
|||
def get_author(title: str) -> str:
|
||||
"""Look up the author of a book in the catalog."""
|
||||
if title not in CATALOG:
|
||||
raise ValueError(f"No book titled {title!r} in the catalog.")
|
||||
raise ToolError(f"No book titled {title!r} in the catalog.")
|
||||
return CATALOG[title]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
|
||||
from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
|
||||
|
||||
mcp = MCPServer("Weather")
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ FORECASTS = {"London": "Rain.", "Cairo": "Sun."}
|
|||
def forecast(city: str) -> str:
|
||||
"""Today's forecast for one city."""
|
||||
if city not in FORECASTS:
|
||||
raise ValueError(f"No forecast for {city!r}.")
|
||||
raise ToolError(f"No forecast for {city!r}.")
|
||||
return FORECASTS[city]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from pathlib import Path
|
||||
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
|
||||
from mcp.shared.path_security import safe_join
|
||||
|
||||
mcp = MCPServer("Bookshop")
|
||||
|
|
@ -11,4 +12,7 @@ DOCS_ROOT = Path("./manuals")
|
|||
@mcp.resource("manuals://{+path}")
|
||||
def read_manual(path: str) -> str:
|
||||
"""A staff manual page, served from a directory on disk."""
|
||||
return safe_join(DOCS_ROOT, path).read_text(encoding="utf-8")
|
||||
file = safe_join(DOCS_ROOT, path)
|
||||
if not file.is_file():
|
||||
raise ResourceNotFoundError(f"No manual at {path!r}.")
|
||||
return file.read_text(encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from typing import Annotated, Any
|
|||
import click
|
||||
from mcp.server import ServerRequestContext
|
||||
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
from mcp.server.mcpserver.prompts.base import Prompt, UserMessage
|
||||
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
|
@ -328,7 +329,7 @@ async def test_elicitation_sep1330_enums(ctx: Context) -> str:
|
|||
@mcp.tool()
|
||||
def test_error_handling() -> str:
|
||||
"""Tests error response handling"""
|
||||
raise RuntimeError("This tool intentionally returns an error for testing")
|
||||
raise ToolError("This tool intentionally returns an error for testing")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
|
|
|
|||
|
|
@ -169,8 +169,12 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
|
|||
The resource content as either text or bytes
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundError: If no resource or template matches the URI.
|
||||
ResourceError: If template creation or resource reading fails.
|
||||
ResourceNotFoundError: If no resource or template matches the URI, or the
|
||||
handler raised it.
|
||||
ResourceError: If the resource or template function raises `ResourceError`.
|
||||
UnexpectedResourceError: If the resource or template function raises anything
|
||||
else. `__cause__` is the original exception. Left uncaught in a tool, this
|
||||
is logged as the tool's crash, while the two above are not.
|
||||
RuntimeError: If the resource returned an `InputRequiredResult`.
|
||||
"""
|
||||
assert self._mcp_server is not None, "Context is not available outside of a request"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue