Skip to content

Commit ab89da8

Browse files
committed
Keep unexpected exception text out of tool results
A tool that crashed used to send the exception's own text to the client as "Error executing tool <name>: <str(exc)>". That text can describe server internals (or, for an output-schema failure, echo the tool's return value), so a crash now reads just "Error executing tool <name>". ToolError, ResourceError, and argument-validation messages still reach the model unchanged, since those are the anticipated failures it can act on. Closes the tool half of the leak that resources already avoided and that prompts stopped doing earlier in this branch. Related tidy-ups in the same direction: - a crashing @mcp.completion() handler is logged once and answered with -32603 "Error completing argument <name>" instead of str(exc) - the legacy resolver path reports a malformed elicitation answer as a ToolError, matching what the input_required path already did - the INFO line for rejected arguments names the fields, not the values Docs now teach ToolError as the way to talk to the model and describe a plain exception as a crash the model sees generically; examples that relied on ValueError text reaching the client raise ToolError instead.
1 parent 9e6d1d9 commit ab89da8

28 files changed

Lines changed: 277 additions & 180 deletions

docs/client/index.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ That schema is everything a UI needs to render an argument form, and everything
8181

8282
`call_tool(name, arguments)` runs the tool and gives you back a `CallToolResult`.
8383

84-
```python title="client.py" hl_lines="26-33"
84+
```python title="client.py" hl_lines="27-34"
8585
--8<-- "docs_src/client/tutorial003.py"
8686
```
8787

@@ -113,17 +113,18 @@ A tool that raises does **not** raise in your client. It comes back as an ordina
113113

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

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

124-
The exception's message landed in `content`, where the **model** can read it and try again. That
125-
is deliberate: a tool error is part of the conversation, not a crash. Always look at `is_error`
126-
before you trust `structured_content`.
124+
The `ToolError`'s message landed in `content`, where the **model** can read it and try again. That
125+
is deliberate: a tool error is part of the conversation, not a crash. (Had the tool crashed with
126+
some other exception, `content` would say only `Error executing tool lookup_book`.) Always look at
127+
`is_error` before you trust `structured_content`.
127128

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

docs/deprecated.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,11 @@ That is the whole API. There is no per-method switch, and you don't want one: th
119119
Run the filter the other way and you get a free regression test. Add
120120
`"error::mcp.MCPDeprecationWarning"` to the `filterwarnings` setting in your pytest
121121
configuration and the deprecated call **raises** instead of warning. A tool named
122-
`old_log` that still calls `ctx.info()` stops passing and starts reporting:
122+
`old_log` that still calls `ctx.info()` stops passing: the call comes back `is_error=True` with
123+
`Error executing tool old_log`, and the captured server log names the culprit:
123124

124125
```text
125-
Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
126+
mcp.MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
126127
```
127128

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

docs/handlers/elicitation.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ That schema is the form. `Field(description=...)` is the label; a default pre-fi
8484
!!! warning
8585
An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields
8686
only: `str`, `int`, `float`, `bool`, or a `Literal` of strings (it becomes an `enum`).
87-
Put a model inside the model and `ctx.elicit` raises before anything is sent to the client:
87+
Put a model inside the model and `ctx.elicit` raises before anything is sent to the client.
88+
The tool call fails with `Error executing tool <name>`, and your server log has the reason:
8889

8990
```text
9091
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
107108

108109
!!! tip
109110
The answer is validated against your model before your code sees it. A client that sends
110-
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a
111-
schema-mismatch error, your `if` never runs.
111+
`"maybe"` for a `bool` doesn't corrupt your booking: `ctx.elicit` raises `ValueError`, the call
112+
fails, and your `if` never runs.
112113

113114
## Send the user to a URL
114115

docs/handlers/logging.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ The default is `"INFO"`.
4949

5050
`logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins.
5151

52-
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#what-the-server-logs)** explains what gets logged and at which level.
52+
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.
5353

5454
## Try it
5555

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2737,7 +2737,7 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve
27372737

27382738
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.
27392739

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:
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 ...`, 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:
27412741

27422742
```toml
27432743
[tool.pytest.ini_options]

docs/servers/handling-errors.md

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
# Handling errors
22

3-
A tool can fail in two ways, and the SDK treats them very differently.
3+
A tool can fail in three ways, and the SDK treats each differently.
44

5-
Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the **protocol** sees it.
5+
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.
66

77
This page is about choosing.
88

99
## An error the model can fix
1010

1111
Take a tool that looks something up, and let the lookup miss:
1212

13-
```python title="server.py" hl_lines="11-12"
13+
```python title="server.py" hl_lines="2 12-13"
1414
--8<-- "docs_src/handling_errors/tutorial001.py"
1515
```
1616

17-
There is nothing MCP about those two lines. `get_author` raises a plain `ValueError`, the way any Python function would.
17+
`ToolError`, from `mcp.server.mcpserver.exceptions`, is how a tool tells the model that something went wrong.
1818

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

@@ -25,21 +25,23 @@ result.structured_content # None
2525
```
2626

2727
* 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.
28+
* `is_error` is `True`, and your message (prefixed with the tool name) is in `content`, exactly where the model reads.
2929
* `structured_content` is `None`. A failed call has no return value to structure.
3030

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.
31+
This is a **tool error**, and it is almost always what you want.
3232

3333
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.
3434

35+
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.
36+
3537
!!! tip
3638
Never `return` an error message from a tool. A returned string has `is_error=False`, so to the
3739
model (and to every client UI) it looks like the tool worked and that string was the answer.
3840
`raise`. The flag is the signal.
3941

4042
## An error the model cannot fix
4143

42-
Now swap `ValueError` for `MCPError`.
44+
Now swap `ToolError` for `MCPError`.
4345

4446
```python title="server.py" hl_lines="1 3 14"
4547
--8<-- "docs_src/handling_errors/tutorial002.py"
@@ -72,10 +74,10 @@ Now swap `ValueError` for `MCPError`.
7274

7375
The two paths answer two different questions.
7476

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.
77+
* **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.
7678
* **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.
7779

78-
One question decides it: **could a smarter model have avoided this?** Yes -> ordinary exception. No -> `MCPError`.
80+
One question decides it: **could a smarter model have avoided this?** Yes -> `ToolError`. No -> `MCPError`.
7981

8082
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.
8183

@@ -84,6 +86,25 @@ By that test, the second version of `get_author` made the wrong choice: a better
8486
`data` payload. Whatever you put in them is what the client receives: the SDK forwards a raised
8587
`MCPError` verbatim instead of sanitising it.
8688

89+
## Any other exception
90+
91+
Now take the check out and let the dictionary lookup fail on its own:
92+
93+
```python title="server.py" hl_lines="11"
94+
--8<-- "docs_src/handling_errors/tutorial004.py"
95+
```
96+
97+
`CATALOG[title]` raises `KeyError`. You didn't plan for it, so the SDK treats it as a crash:
98+
99+
```python
100+
result.is_error # True
101+
result.content # [TextContent(text="Error executing tool get_author")]
102+
```
103+
104+
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.
105+
106+
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.
107+
87108
## A resource that doesn't exist
88109

89110
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
104125
}
105126
```
106127

107-
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)**.
128+
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)**.
108129

109130
## Errors you never raise
110131

@@ -115,36 +136,21 @@ Send `get_author` a `title` that isn't a string and the SDK rejects it against t
115136
It means a whole class of `raise` statements you don't write: don't re-validate your own type hints.
116137

117138
!!! info
118-
Everything so far is what a **client** sees, and the in-memory `Client` you'll write tests
119-
with sees exactly the same thing. Even `raise_exceptions=True` doesn't hand a failing tool's
120-
exception back to the caller: by the time that flag could act, your exception is already the
121-
`is_error=True` result. Assert on the result. If you need the traceback, it is in the server's
122-
log (next section), and pytest's `caplog` captures it. **[Testing](../get-started/testing.md)** covers the pattern.
123-
124-
## What the server logs
125-
126-
The server also logs tool and resource failures, and how it logs them depends on whether you anticipated the failure.
127-
128-
`get_author` raised a plain `ValueError`. The model got the message, but the SDK can't tell that you raised it on purpose, so it treats the call as a crash and logs it at `ERROR` with the full traceback. That is what you want on the day the exception is a `KeyError` from deep inside a library and the result text says only `'id'`.
129-
130-
When the failure is one you planned for, say so with `ToolError`:
131-
132-
```python title="server.py" hl_lines="2 12-13"
133-
--8<-- "docs_src/handling_errors/tutorial004.py"
134-
```
135-
136-
`ToolError` comes from `mcp.server.mcpserver.exceptions`. The model reads exactly what it read before. The difference is in your log, where a `ToolError` is a single `INFO` line with no traceback, so a production log at `WARNING` stays quiet until something is actually broken. Bad arguments and unknown tool names are logged at `INFO` too, because those are the caller's mistakes rather than yours.
137-
138-
Resources work the same way. A crashing resource handler is logged at `ERROR` with its traceback, which matters more here because the `-32603` the client receives names only the URI. `ResourceNotFoundError` and `ResourceError` are the anticipated kind and are logged at `INFO`.
139+
Everything on this page is what a **client** sees, and the in-memory `Client` you'll write
140+
tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't hand a failing
141+
tool's exception back to the caller: by the time that flag could act, your exception is already
142+
the `is_error=True` result. Assert on the result. If you need the traceback of a crash, it is in
143+
the server's log, and pytest's `caplog` captures it. **[Testing](../get-started/testing.md)** covers the pattern.
139144

140145
## Recap
141146

142-
* 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.
147+
* Raise **`ToolError`** in a tool -> the call returns `is_error=True` with your message in `content`. The model reads it and can retry.
143148
* 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.
144-
* The deciding question: *could a smarter model have avoided this?* Yes -> exception. No -> `MCPError`.
149+
* The deciding question: *could a smarter model have avoided this?* Yes -> `ToolError`. No -> `MCPError`.
150+
* 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.
145151
* `ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`.
146152
* Bad arguments are rejected against the schema before your function runs; you don't `raise` for those.
147-
* `from mcp import MCPError`; the error-code constants come from `mcp.types`.
153+
* Imports: `from mcp import MCPError`, `from mcp.server.mcpserver.exceptions import ToolError, ResourceNotFoundError`, and the error-code constants from `mcp.types`.
148154

149155
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)**.
150156

docs/servers/structured-output.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,18 +182,19 @@ You don't notice while you build the value by hand: Pydantic already made sure y
182182
The annotation promises `WeatherData`. The upstream response stopped sending `humidity`.
183183

184184
!!! check
185-
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:
185+
Call `get_weather` and it does not quietly hand the client a half-empty object. The call fails:
186+
the client gets `is_error=True` with `Error executing tool get_weather`, so the model knows the
187+
call failed instead of confidently reading weather that isn't there. The field name is for you,
188+
in the server log at `ERROR`:
187189

188190
```text
189-
Error executing tool get_weather: 1 validation error for WeatherData
191+
Tool 'get_weather' raised an unexpected exception
192+
...
193+
pydantic_core._pydantic_core.ValidationError: 1 validation error for WeatherData
190194
humidity
191195
Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict]
192196
```
193197

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.
196-
197198
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.
198199

199200
## Opting out

0 commit comments

Comments
 (0)