You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: docs/handlers/logging.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -49,7 +49,7 @@ The default is `"INFO"`.
49
49
50
50
`logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins.
51
51
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.
Copy file name to clipboardExpand all lines: docs/migration.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2737,7 +2737,7 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve
2737
2737
2738
2738
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.
2739
2739
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:
Copy file name to clipboardExpand all lines: docs/servers/handling-errors.md
+40-34Lines changed: 40 additions & 34 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,20 +1,20 @@
1
1
# Handling errors
2
2
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.
4
4
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.
6
6
7
7
This page is about choosing.
8
8
9
9
## An error the model can fix
10
10
11
11
Take a tool that looks something up, and let the lookup miss:
12
12
13
-
```python title="server.py" hl_lines="11-12"
13
+
```python title="server.py" hl_lines="2 12-13"
14
14
--8<--"docs_src/handling_errors/tutorial001.py"
15
15
```
16
16
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.
18
18
19
19
Call it with a title that isn't in the catalog and look at the result:
* 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.
29
29
*`structured_content` is `None`. A failed call has no return value to structure.
30
30
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.
32
32
33
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.
34
34
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
+
35
37
!!! tip
36
38
Never `return` an error message from a tool. A returned string has `is_error=False`, so to the
37
39
model (and to every client UI) it looks like the tool worked and that string was the answer.
38
40
`raise`. The flag is the signal.
39
41
40
42
## An error the model cannot fix
41
43
42
-
Now swap `ValueError` for `MCPError`.
44
+
Now swap `ToolError` for `MCPError`.
43
45
44
46
```python title="server.py" hl_lines="1 3 14"
45
47
--8<--"docs_src/handling_errors/tutorial002.py"
@@ -72,10 +74,10 @@ Now swap `ValueError` for `MCPError`.
72
74
73
75
The two paths answer two different questions.
74
76
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.
76
78
***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.
77
79
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`.
79
81
80
82
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.
81
83
@@ -84,6 +86,25 @@ By that test, the second version of `get_author` made the wrong choice: a better
84
86
`data` payload. Whatever you put in them is what the client receives: the SDK forwards a raised
85
87
`MCPError` verbatim instead of sanitising it.
86
88
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:
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
+
87
108
## A resource that doesn't exist
88
109
89
110
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
104
125
}
105
126
```
106
127
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)**.
108
129
109
130
## Errors you never raise
110
131
@@ -115,36 +136,21 @@ Send `get_author` a `title` that isn't a string and the SDK rejects it against t
115
136
It means a whole class of `raise` statements you don't write: don't re-validate your own type hints.
116
137
117
138
!!! 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.
139
144
140
145
## Recap
141
146
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.
143
148
* 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.
145
151
*`ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`.
146
152
* 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`.
148
154
149
155
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)**.
Copy file name to clipboardExpand all lines: docs/servers/structured-output.md
+7-6Lines changed: 7 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -182,18 +182,19 @@ You don't notice while you build the value by hand: Pydantic already made sure y
182
182
The annotation promises `WeatherData`. The upstream response stopped sending `humidity`.
183
183
184
184
!!! 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`:
187
189
188
190
```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
190
194
humidity
191
195
Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict]
192
196
```
193
197
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
-
197
198
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.
0 commit comments