Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ The same text the `@mcp.tool()` version produced. Two honest differences:
MCPError: Internal server error
```

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

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

Expand Down
7 changes: 6 additions & 1 deletion docs/client/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,12 @@ This loop is correct against every server. `MCPServer` returns everything in one

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

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

## Recap

Expand Down
58 changes: 46 additions & 12 deletions docs/get-started/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,52 @@ There you go! You can now extend your tests to cover more scenarios.

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

An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with
`is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or
without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it:
**[Handling errors](../servers/handling-errors.md)**.

A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the
server sanitises it into a generic `"Internal server error"` before the client sees it. You should
never leak the details of an unexpected crash to a remote caller. In a test that is exactly what
you *don't* want, and it is what `raise_exceptions=True` changes: your test sees the real message
instead of the sanitised one.

Leave it on in tests. It has no meaning in production code.
An exception inside one of **your `@mcp.tool()` functions** is not a protocol failure. It becomes a
normal result with `is_error=True`, and the model reads the message. `raise_exceptions` doesn't
change that: with or without it, `call_tool` returns the same `is_error=True` result. There's a
whole page on it: **[Handling errors](../servers/handling-errors.md)**.

An **unexpected exception** that escapes a request handler as a bare Python exception is
different. On an in-memory `Client(server)` connection the SDK still turns that into an
`MCPError` (`-32603`) — the call raises; it does not become an `is_error` result — but by default
it sanitises the message to `"Internal server error"` and drops the original exception. You should
never leak a traceback to a remote caller. In a test that is exactly what you *don't* want.

`raise_exceptions=True` keeps the `MCPError`, but puts `str(original)` in the message and chains
the original as `__cause__`. Catch it *inside* the `async with` so anyio does not wrap it in an
`ExceptionGroup` (**[Troubleshooting](../troubleshooting.md)**):

```python title="test_buggy_handler.py"
import pytest
from mcp import Client, MCPError
from mcp.types import INTERNAL_ERROR

from server import server # a low-level Server whose handler can KeyError


@pytest.mark.anyio
async def test_missing_argument_surfaces_the_real_key_error():
async with Client(server, raise_exceptions=True) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("search_books", {"query": "dune"}) # no limit
assert exc_info.value.error.code == INTERNAL_ERROR
assert isinstance(exc_info.value.__cause__, KeyError)
```

The server that makes that failure visible is a low-level `Server` (it does not validate
`input_schema` before calling you):

```python title="server.py"
--8<-- "docs_src/testing/tutorial002.py"
```

Without the flag, the same call raises `MCPError: Internal server error` with no `__cause__`.
With a high-level `MCPServer`, most handler failures are already converted into tool
`is_error` results or intentional `MCPError`s before this flag can act — so the difference shows
up mainly for unmapped crashes (and for low-level `Server` handlers).

Leave it on in tests that use the in-memory client. It is ignored for `Client("https://...")`
and for a user-supplied `Transport`: those paths never see the flag.

## In-process by default

Expand Down
33 changes: 33 additions & 0 deletions docs_src/testing/tutorial002.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from mcp.server import Server, ServerRequestContext
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)

BUGGY = Tool(
name="search_books",
description="Search the catalog by title or author.",
input_schema={
"type": "object",
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
"required": ["query", "limit"],
},
)


async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[BUGGY])


async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
args = params.arguments or {}
# Missing `limit` reaches the handler: low-level Server does not validate input_schema.
text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})."
return CallToolResult(content=[TextContent(type="text", text=text)])


server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool)
19 changes: 17 additions & 2 deletions src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,24 @@ async def main():

_: KW_ONLY

# TODO(Marcelo): When do `raise_exceptions=True` actually raises?
raise_exceptions: bool = False
"""Whether to raise exceptions from the server."""
"""Unsanitize unexpected in-process handler failures (tests only).

Only has an effect for in-memory ``Client(server)`` connections. Ignored for
URL strings and user-supplied ``Transport`` instances.

On the default modern in-process path, an unmapped handler exception still
surfaces to the caller as ``MCPError`` either way. With ``False`` (the
default) the message is the opaque ``"Internal server error"`` and there is
no ``__cause__``. With ``True`` the message is ``str(original)`` and the
original exception is chained as ``__cause__``.

Does **not** turn a tool's ``is_error=True`` result into an exception, and
does not change intentional ``MCPError`` raised by a handler. Catch
``MCPError`` *inside* ``async with Client(...)`` so anyio does not wrap it
in an ``ExceptionGroup``; see **Testing** and **Troubleshooting** in the
docs.
"""

read_timeout_seconds: float | None = None
"""Timeout for read operations."""
Expand Down
31 changes: 26 additions & 5 deletions tests/docs_src/test_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,41 @@

import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, TextContent
from mcp_types import INTERNAL_ERROR, CallToolResult, TextContent

from docs_src.testing.tutorial001 import mcp
from mcp import Client
from docs_src.testing import tutorial001, tutorial002
from mcp import Client, MCPError
from tests.docs_src._helpers import strip_server_info

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


async def test_call_add_tool() -> None:
async with Client(mcp, raise_exceptions=True) as client:
"""tutorial001: the page's fixture-shaped happy path with `raise_exceptions=True`."""
async with Client(tutorial001.mcp, raise_exceptions=True) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
result = strip_server_info(result, mcp)
result = strip_server_info(result, tutorial001.mcp)
assert result == snapshot(
CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3})
)


async def test_raise_exceptions_true_chains_the_original_handler_error() -> None:
"""The `Why raise_exceptions=True?` section: still `MCPError`, but message and `__cause__` are real."""
async with Client(tutorial002.server, raise_exceptions=True) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("search_books", {"query": "dune"})
assert exc_info.value.error.code == INTERNAL_ERROR
assert isinstance(exc_info.value.__cause__, KeyError)
assert exc_info.value.__cause__.args == ("limit",)


async def test_raise_exceptions_false_sanitises_the_handler_error() -> None:
"""Without the flag, the same low-level crash is the opaque `"Internal server error"`."""
async with Client(tutorial002.server, raise_exceptions=False) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("search_books", {"query": "dune"})
assert exc_info.value.error.code == INTERNAL_ERROR
assert exc_info.value.error.message == "Internal server error"
assert exc_info.value.__cause__ is None
Loading