Skip to content

Commit 0840a9f

Browse files
committed
docs: clarify when Client(raise_exceptions=True) actually raises.
Document that the flag only unsanitises unexpected in-memory handler crashes (still MCPError, with message/__cause__), leaves tool is_error results alone, and is ignored for URL/transport clients. Fixes #3287.
1 parent 6e30452 commit 0840a9f

7 files changed

Lines changed: 138 additions & 21 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ The same text the `@mcp.tool()` version produced. Two honest differences:
7272
MCPError: Internal server error
7373
```
7474

75-
A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **[Testing](../get-started/testing.md)**.)
75+
A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `Client(server, raise_exceptions=True)` keeps the `MCPError` but puts the real message on it and chains the original as `__cause__`; see **[Testing](../get-started/testing.md)**.)
7676

7777
That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **[Handling errors](../servers/handling-errors.md)**.
7878

docs/client/index.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,12 @@ This loop is correct against every server. `MCPServer` returns everything in one
197197

198198
`Client(mcp)` with no process and no port is already a test harness for your server.
199199

200-
There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../get-started/testing.md)** is the page that explains it and builds the whole pattern around it.
200+
There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an
201+
effect on in-memory connections (ignored for URL strings and transports). On the modern
202+
in-process path it does **not** make the original exception raise in place of `MCPError` — it
203+
unsanitises an unexpected handler crash so the `MCPError` message is `str(original)` and
204+
`__cause__` is the original. Tool `is_error=True` results are unchanged. **[Testing](../get-started/testing.md)**
205+
builds the whole pattern around it.
201206

202207
## Recap
203208

docs/get-started/testing.md

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,52 @@ 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:
84-
**[Handling errors](../servers/handling-errors.md)**.
85-
86-
A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the
87-
server sanitises it into a generic `"Internal server error"` before the client sees it. You should
88-
never leak the details of an unexpected crash to a remote caller. In a test that is exactly what
89-
you *don't* want, and it is what `raise_exceptions=True` changes: your test sees the real message
90-
instead of the sanitised one.
91-
92-
Leave it on in tests. It has no meaning in production code.
81+
An exception inside one of **your `@mcp.tool()` functions** is not a protocol failure. It becomes a
82+
normal result with `is_error=True`, and the model reads the message. `raise_exceptions` doesn't
83+
change that: with or without it, `call_tool` returns the same `is_error=True` result. There's a
84+
whole page on it: **[Handling errors](../servers/handling-errors.md)**.
85+
86+
An **unexpected exception** that escapes a request handler as a bare Python exception is
87+
different. On an in-memory `Client(server)` connection the SDK still turns that into an
88+
`MCPError` (`-32603`) — the call raises; it does not become an `is_error` result — but by default
89+
it sanitises the message to `"Internal server error"` and drops the original exception. You should
90+
never leak a traceback to a remote caller. In a test that is exactly what you *don't* want.
91+
92+
`raise_exceptions=True` keeps the `MCPError`, but puts `str(original)` in the message and chains
93+
the original as `__cause__`. Catch it *inside* the `async with` so anyio does not wrap it in an
94+
`ExceptionGroup` (**[Troubleshooting](../troubleshooting.md)**):
95+
96+
```python title="test_buggy_handler.py"
97+
import pytest
98+
from mcp import Client, MCPError
99+
from mcp.types import INTERNAL_ERROR
100+
101+
from server import server # a low-level Server whose handler can KeyError
102+
103+
104+
@pytest.mark.anyio
105+
async def test_missing_argument_surfaces_the_real_key_error():
106+
async with Client(server, raise_exceptions=True) as client:
107+
with pytest.raises(MCPError) as exc_info:
108+
await client.call_tool("search_books", {"query": "dune"}) # no limit
109+
assert exc_info.value.error.code == INTERNAL_ERROR
110+
assert isinstance(exc_info.value.__cause__, KeyError)
111+
```
112+
113+
The server that makes that failure visible is a low-level `Server` (it does not validate
114+
`input_schema` before calling you):
115+
116+
```python title="server.py"
117+
--8<-- "docs_src/testing/tutorial002.py"
118+
```
119+
120+
Without the flag, the same call raises `MCPError: Internal server error` with no `__cause__`.
121+
With a high-level `MCPServer`, most handler failures are already converted into tool
122+
`is_error` results or intentional `MCPError`s before this flag can act — so the difference shows
123+
up mainly for unmapped crashes (and for low-level `Server` handlers).
124+
125+
Leave it on in tests that use the in-memory client. It is ignored for `Client("https://...")`
126+
and for a user-supplied `Transport`: those paths never see the flag.
93127

94128
## In-process by default
95129

docs/troubleshooting.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ async def main() -> None:
5656
down this page) escapes from `async with` itself, so there is no "inside" to catch it in.
5757
For those, read the bottom of the group.
5858

59+
!!! tip
60+
Seeing only `MCPError: Internal server error` in an in-memory test? That is the sanitised
61+
form of an unexpected handler crash. `Client(mcp, raise_exceptions=True)` keeps the
62+
`MCPError` but puts the real message on it and chains the original as `__cause__` — still
63+
catch `MCPError` inside the block. The flag is ignored for URL/transport clients, does not
64+
turn a tool's `is_error=True` into an exception, and should be dropped on
65+
`mode="legacy"`. **[Testing](get-started/testing.md)** is the full story.
66+
5967
## `RuntimeError: Client must be used within an async context manager`
6068

6169
`Client(...)` only builds the object. Nothing connects until `async with`, so every method refuses:
@@ -404,6 +412,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
404412
## Recap
405413

406414
* `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.
415+
* In-memory `MCPError: Internal server error` is a sanitised handler crash; `raise_exceptions=True` unsanitises the message and `__cause__` (see **[Testing](get-started/testing.md)**).
407416
* `call_tool` does not raise for a failing tool. `Error executing tool ...` and `Unknown tool: ...` are results: check `result.is_error`.
408417
* `Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> add the parentheses.
409418
* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.

docs_src/testing/tutorial002.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from mcp.server import Server, ServerRequestContext
2+
from mcp.types import (
3+
CallToolRequestParams,
4+
CallToolResult,
5+
ListToolsResult,
6+
PaginatedRequestParams,
7+
TextContent,
8+
Tool,
9+
)
10+
11+
BUGGY = Tool(
12+
name="search_books",
13+
description="Search the catalog by title or author.",
14+
input_schema={
15+
"type": "object",
16+
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
17+
"required": ["query", "limit"],
18+
},
19+
)
20+
21+
22+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
23+
return ListToolsResult(tools=[BUGGY])
24+
25+
26+
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
27+
args = params.arguments or {}
28+
# Missing `limit` reaches the handler: low-level Server does not validate input_schema.
29+
text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})."
30+
return CallToolResult(content=[TextContent(type="text", text=text)])
31+
32+
33+
server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool)

src/mcp/client/client.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,9 +293,24 @@ async def main():
293293

294294
_: KW_ONLY
295295

296-
# TODO(Marcelo): When do `raise_exceptions=True` actually raises?
297296
raise_exceptions: bool = False
298-
"""Whether to raise exceptions from the server."""
297+
"""Unsanitize unexpected in-process handler failures (tests only).
298+
299+
Only has an effect for in-memory ``Client(server)`` connections. Ignored for
300+
URL strings and user-supplied ``Transport`` instances.
301+
302+
On the default modern in-process path, an unmapped handler exception still
303+
surfaces to the caller as ``MCPError`` either way. With ``False`` (the
304+
default) the message is the opaque ``"Internal server error"`` and there is
305+
no ``__cause__``. With ``True`` the message is ``str(original)`` and the
306+
original exception is chained as ``__cause__``.
307+
308+
Does **not** turn a tool's ``is_error=True`` result into an exception, and
309+
does not change intentional ``MCPError`` raised by a handler. Catch
310+
``MCPError`` *inside* ``async with Client(...)`` so anyio does not wrap it
311+
in an ``ExceptionGroup``; see **Testing** and **Troubleshooting** in the
312+
docs.
313+
"""
299314

300315
read_timeout_seconds: float | None = None
301316
"""Timeout for read operations."""

tests/docs_src/test_testing.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,41 @@
66

77
import pytest
88
from inline_snapshot import snapshot
9-
from mcp_types import CallToolResult, TextContent
9+
from mcp_types import INTERNAL_ERROR, CallToolResult, TextContent
1010

11-
from docs_src.testing.tutorial001 import mcp
12-
from mcp import Client
11+
from docs_src.testing import tutorial001, tutorial002
12+
from mcp import Client, MCPError
1313
from tests.docs_src._helpers import strip_server_info
1414

1515
# See test_index.py for why this is a per-module mark and not a conftest hook.
1616
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
1717

1818

1919
async def test_call_add_tool() -> None:
20-
async with Client(mcp, raise_exceptions=True) as client:
20+
"""tutorial001: the page's fixture-shaped happy path with `raise_exceptions=True`."""
21+
async with Client(tutorial001.mcp, raise_exceptions=True) as client:
2122
result = await client.call_tool("add", {"a": 1, "b": 2})
22-
result = strip_server_info(result, mcp)
23+
result = strip_server_info(result, tutorial001.mcp)
2324
assert result == snapshot(
2425
CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3})
2526
)
27+
28+
29+
async def test_raise_exceptions_true_chains_the_original_handler_error() -> None:
30+
"""The `Why raise_exceptions=True?` section: still `MCPError`, but message and `__cause__` are real."""
31+
async with Client(tutorial002.server, raise_exceptions=True) as client:
32+
with pytest.raises(MCPError) as exc_info:
33+
await client.call_tool("search_books", {"query": "dune"})
34+
assert exc_info.value.error.code == INTERNAL_ERROR
35+
assert isinstance(exc_info.value.__cause__, KeyError)
36+
assert exc_info.value.__cause__.args == ("limit",)
37+
38+
39+
async def test_raise_exceptions_false_sanitises_the_handler_error() -> None:
40+
"""Without the flag, the same low-level crash is the opaque `"Internal server error"`."""
41+
async with Client(tutorial002.server, raise_exceptions=False) as client:
42+
with pytest.raises(MCPError) as exc_info:
43+
await client.call_tool("search_books", {"query": "dune"})
44+
assert exc_info.value.error.code == INTERNAL_ERROR
45+
assert exc_info.value.error.message == "Internal server error"
46+
assert exc_info.value.__cause__ is None

0 commit comments

Comments
 (0)