ab89da82ba
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.
123 lines
6.2 KiB
Python
123 lines
6.2 KiB
Python
"""`docs/servers/handling-errors.md`: every claim the page makes, proved against the real SDK."""
|
|
|
|
import logging
|
|
|
|
import pytest
|
|
from mcp_types import INVALID_PARAMS, ErrorData, TextContent, TextResourceContents
|
|
|
|
from docs_src.handling_errors import tutorial001, tutorial002, tutorial003, tutorial004
|
|
from mcp import Client, MCPError
|
|
|
|
# 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_tool_error_becomes_a_tool_error_the_model_reads() -> None:
|
|
"""tutorial001: `ToolError` comes back as `is_error=True` with the message in `content`."""
|
|
async with Client(tutorial001.mcp) as client:
|
|
result = await client.call_tool("get_author", {"title": "Nothing"})
|
|
assert result.is_error
|
|
assert result.content == [
|
|
TextContent(type="text", text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")
|
|
]
|
|
assert result.structured_content is None
|
|
|
|
|
|
async def test_tool_error_is_one_info_line(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""tutorial001: on the server a `ToolError` is one INFO record with no traceback."""
|
|
caplog.set_level(logging.INFO)
|
|
async with Client(tutorial001.mcp) as client:
|
|
await client.call_tool("get_author", {"title": "Nothing"})
|
|
records = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"]
|
|
assert [(r.levelno, r.exc_info) for r in records] == [(logging.INFO, None)]
|
|
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
|
|
|
|
|
|
async def test_a_title_the_catalog_knows_is_an_ordinary_result() -> None:
|
|
"""tutorial001: the non-raising path is a plain `is_error=False` result."""
|
|
async with Client(tutorial001.mcp) as client:
|
|
result = await client.call_tool("get_author", {"title": "Dune"})
|
|
assert not result.is_error
|
|
assert result.structured_content == {"result": "Frank Herbert"}
|
|
|
|
|
|
async def test_a_bad_argument_never_reaches_the_function() -> None:
|
|
"""tutorial001: schema validation rejects the call before `get_author` runs, as the same kind of tool error."""
|
|
async with Client(tutorial001.mcp) as client:
|
|
result = await client.call_tool("get_author", {"title": 42})
|
|
assert result.is_error
|
|
assert isinstance(result.content[0], TextContent)
|
|
assert "Input should be a valid string" in result.content[0].text
|
|
|
|
|
|
async def test_mcp_error_makes_the_call_itself_fail() -> None:
|
|
"""tutorial002: `MCPError` is not caught. It surfaces as a JSON-RPC error, with `code` and `message` intact."""
|
|
async with Client(tutorial002.mcp) as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.call_tool("get_author", {"title": "Nothing"})
|
|
assert exc_info.value.code == INVALID_PARAMS
|
|
assert exc_info.value.message == "No book titled 'Nothing' in the catalog."
|
|
|
|
|
|
async def test_mcp_error_only_fires_on_the_raising_path() -> None:
|
|
"""tutorial002: a title the catalog knows still returns a normal result."""
|
|
async with Client(tutorial002.mcp) as client:
|
|
result = await client.call_tool("get_author", {"title": "Dune"})
|
|
assert not result.is_error
|
|
assert result.structured_content == {"result": "Frank Herbert"}
|
|
|
|
|
|
async def test_any_other_exception_is_a_crash_the_model_sees_generically(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""tutorial004, "Any other exception": the `KeyError` text stays on the server; the model gets only the
|
|
generic line, and the log gets one ERROR record with the traceback under the documented message."""
|
|
caplog.set_level(logging.INFO)
|
|
async with Client(tutorial004.mcp) as client:
|
|
result = await client.call_tool("get_author", {"title": "Nothing"})
|
|
assert result.is_error
|
|
assert result.content == [TextContent(type="text", text="Error executing tool get_author")]
|
|
(record,) = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"]
|
|
assert (record.levelno, record.getMessage()) == (logging.ERROR, "Tool 'get_author' raised an unexpected exception")
|
|
assert record.exc_info is not None
|
|
logged = record.exc_info[1]
|
|
assert logged is not None and isinstance(logged.__cause__, KeyError)
|
|
|
|
|
|
async def test_resource_not_found_error_maps_to_invalid_params() -> None:
|
|
"""tutorial003: `ResourceNotFoundError` from a template handler is `-32602` with the URI in `data`."""
|
|
async with Client(tutorial003.mcp) as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.read_resource("books://Nothing")
|
|
assert exc_info.value.error == ErrorData(
|
|
code=INVALID_PARAMS,
|
|
message="No book titled 'Nothing' in the catalog.",
|
|
data={"uri": "books://Nothing"},
|
|
)
|
|
|
|
|
|
async def test_raise_exceptions_does_not_turn_a_tool_error_into_a_traceback() -> None:
|
|
"""The closing `!!! info`: even `raise_exceptions=True` leaves a failing tool as the `is_error=True` result."""
|
|
async with Client(tutorial004.mcp, raise_exceptions=True) as client:
|
|
result = await client.call_tool("get_author", {"title": "Nothing"})
|
|
assert result.is_error
|
|
assert result.content == [TextContent(type="text", text="Error executing tool get_author")]
|
|
|
|
|
|
async def test_a_title_the_template_knows_reads_normally() -> None:
|
|
"""tutorial003: the non-raising path resolves the template and returns text contents."""
|
|
async with Client(tutorial003.mcp) as client:
|
|
result = await client.read_resource("books://Dune")
|
|
(contents,) = result.contents
|
|
assert isinstance(contents, TextResourceContents)
|
|
assert contents.text == "Dune by Frank Herbert"
|
|
|
|
|
|
async def test_a_bad_argument_is_an_info_line_not_a_crash(caplog: pytest.LogCaptureFixture) -> None:
|
|
""" "Errors you never raise": schema rejection of the arguments is logged at INFO with no traceback."""
|
|
caplog.set_level(logging.INFO)
|
|
async with Client(tutorial001.mcp) as client:
|
|
result = await client.call_tool("get_author", {"title": 42})
|
|
assert result.is_error
|
|
records = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"]
|
|
assert [(r.levelno, r.exc_info) for r in records] == [(logging.INFO, None)]
|
|
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
|