Skip to content

Commit 1bea479

Browse files
committed
fix(server): sanitize unexpected tool errors
1 parent 6e30452 commit 1bea479

35 files changed

Lines changed: 190 additions & 136 deletions

docs/client/index.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,17 +112,17 @@ A tool that raises does **not** raise in your client. It comes back as an ordina
112112

113113
!!! check
114114
Ask `lookup_book` for `"Solaris"` (a title that isn't in the catalog) and the function raises
115-
`ValueError`. The call still returns normally:
115+
`ToolError`. The call still returns normally:
116116

117117
```python
118118
result.is_error # True
119-
result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")]
119+
result.content # [TextContent(type='text', text="No book titled 'Solaris' in the catalog.")]
120120
result.structured_content # None
121121
```
122122

123-
The exception's message landed in `content`, where the **model** can read it and try again. That
124-
is deliberate: a tool error is part of the conversation, not a crash. Always look at `is_error`
125-
before you trust `structured_content`.
123+
The deliberate `ToolError` message landed in `content`, where the **model** can read it and try
124+
again. Unexpected exceptions are logged on the server and replaced with a generic message. Always
125+
look at `is_error` before you trust `structured_content`.
126126

127127
!!! warning
128128
`is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have

docs/deprecated.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ That is the whole API. There is no per-method switch, and you don't want one: th
7373
`old_log` that still calls `ctx.info()` stops passing and starts reporting:
7474

7575
```text
76-
Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
76+
An unexpected error occurred while executing tool old_log
7777
```
7878

7979
One line of pytest configuration, and a deprecated call can never sneak back into your

docs/get-started/testing.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ There you go! You can now extend your tests to cover more scenarios.
7878

7979
Two different things can go wrong, and this flag only touches one of them.
8080

81-
An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with
82-
`is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or
83-
without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it:
81+
An explicit `ToolError` inside one of **your tools** is not a protocol failure. It becomes a normal
82+
result with `is_error=True` and its safe message in the content. An unexpected exception is logged
83+
server-side and becomes a normal result with a generic message. `raise_exceptions` doesn't change that:
84+
with or without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it:
8485
**[Handling errors](../servers/handling-errors.md)**.
8586

8687
A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the

docs/handlers/dependencies.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ And if the user won't answer at all - declines the question, or cancels it?
111111
result the model can read:
112112

113113
```text
114-
Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline
114+
Resolver for parameter 'backorder' could not resolve: elicitation was decline
115115
```
116116

117117
That's the right default for a precondition: no answer, no order. When declining is an outcome your tool wants to handle - skip the backorder but still suggest another title - annotate `ElicitationResult[Backorder]` instead and the tool receives the full accept/decline/cancel outcome to branch on. **[Elicitation](elicitation.md)** shows that form, and everything else about asking: the schema rules, the three answers, the client's side of the conversation.

docs/migration.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -992,8 +992,10 @@ its behavior is unchanged.
992992
`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it
993993
when the request itself should be rejected (missing client capability,
994994
elicitation required, invalid parameters). For tool *execution* failures the
995-
calling LLM should see and react to, raise any other exception or return
996-
`CallToolResult(is_error=True, ...)` directly; that path is unchanged.
995+
calling LLM should see and react to, raise `ToolError` with a safe message or
996+
return `CallToolResult(is_error=True, ...)` directly. Unexpected exceptions are
997+
logged server-side and returned as a generic `is_error=True` result instead of
998+
exposing their values to the client.
997999

9981000
The client sees this change too. `Client.call_tool()` and
9991001
`ClientSession.call_tool()` raise on a JSON-RPC error response, so a tool that
@@ -2737,7 +2739,7 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve
27372739

27382740
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.
27392741

2740-
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:
2742+
Under pytest's `filterwarnings = ["error"]`, that warning becomes an exception at the first deprecated call. Inside an `@mcp.tool()` handler the exception is logged and returned as `CallToolResult(is_error=True)` (`An unexpected error occurred while executing tool old_log`), which reads as a failing tool rather than a warning. Keep the warnings visible but non-fatal with:
27412743

27422744
```toml
27432745
[tool.pytest.ini_options]

docs/servers/handling-errors.md

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
A tool can fail in two ways, and the SDK treats them very differently.
44

5-
Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the **protocol** sees it.
5+
Raise `ToolError` when the **model** should see a safe, actionable message. Raise `MCPError` when the
6+
**protocol** should see the failure. Unexpected exceptions are logged server-side and replaced with a
7+
generic tool error.
68

79
This page is about choosing.
810

@@ -14,32 +16,39 @@ Take a tool that looks something up, and let the lookup miss:
1416
--8<-- "docs_src/handling_errors/tutorial001.py"
1517
```
1618

17-
There is nothing MCP about those two lines. `get_author` raises a plain `ValueError`, the way any Python function would.
19+
`get_author` raises `ToolError` with the message the model is allowed to see. Use this exception for
20+
expected, recoverable failures such as a missing catalog entry.
1821

1922
Call it with a title that isn't in the catalog and look at the result:
2023

2124
```python
2225
result.is_error # True
23-
result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")]
26+
result.content # [TextContent(text="No book titled 'Nothing' in the catalog.")]
2427
result.structured_content # None
2528
```
2629

2730
* The request **succeeded**. There is a result; nothing was raised at the caller.
28-
* `is_error` is `True`, and your exception's message (prefixed with the tool name) is in `content`, exactly where the model reads.
31+
* `is_error` is `True`, and the `ToolError` message is in `content`, exactly where the model reads.
2932
* `structured_content` is `None`. A failed call has no return value to structure.
3033

31-
This is a **tool error**, and it is the default for *any* exception your tool raises. It is also almost always what you want.
34+
This is a **tool error**. The message is explicit and safe because the tool author chose to raise
35+
`ToolError`.
3236

33-
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.
37+
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 ToolError(...)` and got a self-correcting agent.
38+
39+
!!! warning
40+
If an unexpected exception escapes the tool, the SDK logs the traceback on the server and returns
41+
`An unexpected error occurred while executing tool <name>`. It never sends the exception value to
42+
the client. Use `ToolError` when the model needs a specific recovery hint.
3443

3544
!!! tip
3645
Never `return` an error message from a tool. A returned string has `is_error=False`, so to the
3746
model (and to every client UI) it looks like the tool worked and that string was the answer.
38-
`raise`. The flag is the signal.
47+
`raise ToolError(...)`. The flag is the signal.
3948

4049
## An error the model cannot fix
4150

42-
Now swap `ValueError` for `MCPError`.
51+
Now swap `ToolError` for `MCPError`.
4352

4453
```python title="server.py" hl_lines="1 3 14"
4554
--8<-- "docs_src/handling_errors/tutorial002.py"
@@ -72,12 +81,16 @@ Now swap `ValueError` for `MCPError`.
7281

7382
The two paths answer two different questions.
7483

75-
* **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.
84+
* **Raise `ToolError`** for an expected failure of *execution* that the model can recover from. Include only information that is safe for the client to see: a misspelled title, a row that doesn't exist, or a user-facing validation message.
85+
* Let **unexpected exceptions** propagate when the details are for server operators. The SDK logs the traceback and returns a generic `is_error=True` result.
7686
* **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.
7787

78-
One question decides it: **could a smarter model have avoided this?** Yes -> ordinary exception. No -> `MCPError`.
88+
One question decides it: **does the model need a safe recovery hint?** Yes -> `ToolError`. No, because
89+
the failure is unexpected or internal -> let the original exception be logged and sanitized. If the
90+
request itself is invalid or unsupported -> `MCPError`.
7991

80-
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.
92+
By that test, `get_author` uses `ToolError`: a better title fixes the problem, so the model deserves
93+
to see the message.
8194

8295
!!! info
8396
`MCPError` lives at `from mcp import MCPError` and takes `code`, `message`, and an optional
@@ -110,7 +123,7 @@ Notice there is no `is_error=True` half-result here. A resource read either retu
110123

111124
A bad argument never reaches your function.
112125

113-
Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. **[Tools](tools.md)** shows the same rejection with a `Field(le=50)` constraint.
126+
Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, returning a generic `is_error=True` tool result. The validation details stay in the server log while the model can use the advertised schema to correct its arguments. **[Tools](tools.md)** shows the same rejection with a `Field(le=50)` constraint.
114127

115128
It means a whole class of `raise` statements you don't write: don't re-validate your own type hints.
116129

@@ -122,9 +135,10 @@ It means a whole class of `raise` statements you don't write: don't re-validate
122135

123136
## Recap
124137

125-
* 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.
138+
* Raise **`ToolError`** in a tool -> the call returns `is_error=True` with your safe message in `content`. The model reads it and can retry.
139+
* Let an **unexpected exception** escape -> the server logs the traceback and the call returns `is_error=True` with a generic message.
126140
* 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.
127-
* The deciding question: *could a smarter model have avoided this?* Yes -> exception. No -> `MCPError`.
141+
* The deciding question: *does the model need a safe recovery hint?* Yes -> `ToolError`. No -> let the SDK sanitize the unexpected error, or raise `MCPError` if the request itself should fail.
128142
* `ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`.
129143
* Bad arguments are rejected against the schema before your function runs; you don't `raise` for those.
130144
* `from mcp import MCPError`; the error-code constants come from `mcp.types`.

docs/servers/structured-output.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,14 @@ The annotation promises `WeatherData`. The upstream response stopped sending `hu
183183

184184
!!! check
185185
Call `get_weather` and it does not quietly hand the client a half-empty object. The call fails,
186-
and the first lines of the error name the field:
186+
while the validation details stay in the server log:
187187

188188
```text
189-
Error executing tool get_weather: 1 validation error for WeatherData
190-
humidity
191-
Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict]
189+
An unexpected error occurred while executing tool get_weather
192190
```
193191

194-
That text comes back as the tool result with `is_error=True`, so the model knows the call failed
195-
instead of confidently reading weather that isn't there.
192+
The generic text comes back as the tool result with `is_error=True`, so the model knows the call
193+
failed instead of receiving internal schema details.
196194

197195
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.
198196

docs/servers/tools.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,16 @@ Three new things, all on the parameters:
104104
* `Literal["fiction", "non-fiction", "poetry"]`: an enum. The model can only pick one of those.
105105

106106
!!! check
107-
Constraints are not decoration. Call the tool with `limit=999` and the SDK answers with a
108-
tool error **before your function runs**:
107+
Constraints are not decoration. Call the tool with `limit=999` and the SDK rejects it
108+
**before your function runs**:
109109

110110
```text
111-
Input should be less than or equal to 50
111+
An unexpected error occurred while executing tool search_books
112112
```
113113

114-
That error goes back to the model as the tool result, and the model reads it and retries with
115-
a valid value. You wrote `le=50` once and got self-correcting agents for free.
114+
The validation details stay in the server log; the client never receives Pydantic's internal
115+
model name, version-specific wording, or documentation URL. The model already has the
116+
constraint in the input schema and can retry with a valid value.
116117

117118
!!! info
118119
If you've used FastAPI or Pydantic, you already know all of this. It's the same `Field`,
@@ -166,7 +167,7 @@ A well-behaved client uses them to decide things like *"do I need to ask the use
166167
* Type hints **are** the input schema. Defaults make arguments optional.
167168
* `Annotated[..., Field(...)]` adds descriptions and constraints; `Literal` adds enums.
168169
* A Pydantic model parameter is how you take a structured "body".
169-
* Bad arguments are rejected for you, with an error the model can read and recover from.
170+
* Bad arguments are rejected for you before the function runs; validation details stay server-side.
170171
* `async def` for I/O, plain `def` for everything else.
171172

172173
**[Structured Output](structured-output.md)** is what happens to the value you `return`.

docs/troubleshooting.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,21 +76,25 @@ async def main() -> None:
7676

7777
`__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.
7878

79-
## `Error executing tool <name>: <message>` and `Unknown tool: <name>`
79+
## Tool errors, unexpected errors, and `Unknown tool: <name>`
8080

8181
You are reading a **result**, not an exception. `call_tool` did not raise, and it never will for a failing tool.
8282

83-
Call `forecast` for a city the server doesn't know, and the exception it raises comes back with the request marked as *succeeded*:
83+
Call `forecast` for a city the server doesn't know. Because the tool raises `ToolError`, the safe
84+
message comes back with the request marked as *succeeded*:
8485

8586
```python
8687
result.is_error # True
87-
result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")]
88+
result.content # [TextContent(text="No forecast for 'Atlantis'.")]
8889
result.structured_content # None
8990
```
9091

91-
`Unknown tool: get_forecast` is the same shape for a name the server never registered, and a bad argument is rejected the same way, against the tool's input schema, before your function ever runs.
92+
An unexpected exception uses the same result shape but returns a generic message, while the traceback
93+
is logged on the server. `Unknown tool: get_forecast` is the same shape for a name the server never
94+
registered, and a bad argument is rejected the same way, against the tool's input schema, before your
95+
function ever runs.
9296

93-
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.
97+
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. For a recoverable, model-facing failure, raise `ToolError` with a safe message. **[Handling errors](servers/handling-errors.md)** is the whole story, including the `MCPError` path that *does* raise.
9498

9599
## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool`
96100

@@ -404,7 +408,8 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
404408
## Recap
405409

406410
* `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.
407-
* `call_tool` does not raise for a failing tool. `Error executing tool ...` and `Unknown tool: ...` are results: check `result.is_error`.
411+
* `call_tool` does not raise for a failing high-level tool. `ToolError`, unexpected tool exceptions,
412+
and `Unknown tool: ...` are results: check `result.is_error`.
408413
* `Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> add the parentheses.
409414
* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.
410415
* 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=[...])`.

docs_src/client/tutorial003.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from mcp import Client
44
from mcp.server import MCPServer
5+
from mcp.server.mcpserver.exceptions import ToolError
56
from mcp.types import TextContent
67

78
mcp = MCPServer("Bookshop")
@@ -17,7 +18,7 @@ class Book(BaseModel):
1718
def lookup_book(title: str) -> Book:
1819
"""Look up a book by its exact title."""
1920
if title != "Dune":
20-
raise ValueError(f"No book titled {title!r} in the catalog.")
21+
raise ToolError(f"No book titled {title!r} in the catalog.")
2122
return Book(title="Dune", author="Frank Herbert", year=1965)
2223

2324

0 commit comments

Comments
 (0)