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
Copy file name to clipboardExpand all lines: docs/handlers/dependencies.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
@@ -111,7 +111,7 @@ And if the user won't answer at all - declines the question, or cancels it?
111
111
result the model can read:
112
112
113
113
```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
115
115
```
116
116
117
117
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.
Copy file name to clipboardExpand all lines: docs/migration.md
+5-3Lines changed: 5 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -992,8 +992,10 @@ its behavior is unchanged.
992
992
`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it
993
993
when the request itself should be rejected (missing client capability,
994
994
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.
997
999
998
1000
The client sees this change too. `Client.call_tool()` and
999
1001
`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
2737
2739
2738
2740
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
2741
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:
Copy file name to clipboardExpand all lines: docs/servers/handling-errors.md
+28-14Lines changed: 28 additions & 14 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,7 +2,9 @@
2
2
3
3
A tool can fail in two ways, and the SDK treats them very differently.
4
4
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.
6
8
7
9
This page is about choosing.
8
10
@@ -14,32 +16,39 @@ Take a tool that looks something up, and let the lookup miss:
14
16
--8<--"docs_src/handling_errors/tutorial001.py"
15
17
```
16
18
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.
18
21
19
22
Call it with a title that isn't in the catalog and look at the result:
20
23
21
24
```python
22
25
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.")]
24
27
result.structured_content # None
25
28
```
26
29
27
30
* 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.
29
32
*`structured_content` is `None`. A failed call has no return value to structure.
30
33
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`.
32
36
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.
34
43
35
44
!!! tip
36
45
Never `return` an error message from a tool. A returned string has `is_error=False`, so to the
37
46
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.
39
48
40
49
## An error the model cannot fix
41
50
42
-
Now swap `ValueError` for `MCPError`.
51
+
Now swap `ToolError` for `MCPError`.
43
52
44
53
```python title="server.py" hl_lines="1 3 14"
45
54
--8<--"docs_src/handling_errors/tutorial002.py"
@@ -72,12 +81,16 @@ Now swap `ValueError` for `MCPError`.
72
81
73
82
The two paths answer two different questions.
74
83
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.
76
86
***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
87
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`.
79
91
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.
81
94
82
95
!!! info
83
96
`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
110
123
111
124
A bad argument never reaches your function.
112
125
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.
114
127
115
128
It means a whole class of `raise` statements you don't write: don't re-validate your own type hints.
116
129
@@ -122,9 +135,10 @@ It means a whole class of `raise` statements you don't write: don't re-validate
122
135
123
136
## Recap
124
137
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.
126
140
* 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.
128
142
*`ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`.
129
143
* Bad arguments are rejected against the schema before your function runs; you don't `raise` for those.
130
144
*`from mcp import MCPError`; the error-code constants come from `mcp.types`.
Copy file name to clipboardExpand all lines: docs/servers/structured-output.md
+4-6Lines changed: 4 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -183,16 +183,14 @@ The annotation promises `WeatherData`. The upstream response stopped sending `hu
183
183
184
184
!!! check
185
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:
186
+
while the validation details stay in the server log:
187
187
188
188
```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
192
190
```
193
191
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.
196
194
197
195
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.
Copy file name to clipboardExpand all lines: docs/troubleshooting.md
+11-6Lines changed: 11 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -76,21 +76,25 @@ async def main() -> None:
76
76
77
77
`__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.
78
78
79
-
## `Error executing tool <name>: <message>` and `Unknown tool: <name>`
79
+
## Tool errors, unexpected errors, and `Unknown tool: <name>`
80
80
81
81
You are reading a **result**, not an exception. `call_tool` did not raise, and it never will for a failing tool.
82
82
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*:
84
85
85
86
```python
86
87
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'.")]
88
89
result.structured_content # None
89
90
```
90
91
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.
92
96
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.
94
98
95
99
## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool`
*`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`.
408
413
*`Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> add the parentheses.
409
414
*`Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.
410
415
* 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=[...])`.
0 commit comments