Log MCPServer handler exceptions by kind and keep crash details off the wire (#3314)

Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
This commit is contained in:
Max 2026-08-24 15:51:18 +01:00 • committed by GitHub
commit 56af44727b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1427 additions and 310 deletions

View file

@ -85,7 +85,7 @@ async def test_call_tool_result_has_three_things_to_read() -> None:
async def test_a_raising_tool_is_a_result_not_an_exception() -> None:
"""tutorial003 `!!! check`: the exception's message comes back in content with is_error=True."""
"""tutorial003 `!!! check`: the ToolError's message comes back in content with is_error=True."""
async with Client(tutorial003.mcp) as client:
result = await client.call_tool("lookup_book", {"title": "Solaris"})
assert result.is_error

View file

@ -8,6 +8,7 @@ order on a modern connection, the `ping` removal, and both `filterwarnings` reci
so the prose cannot drift away from what the SDK does.
"""
import logging
import warnings
import pytest
@ -117,20 +118,25 @@ def test_mcp_deprecation_warning_is_a_user_warning() -> None:
@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")
async def test_error_filter_turns_the_deprecated_call_into_the_documented_tool_error() -> None:
async def test_error_filter_turns_the_deprecated_call_into_the_documented_tool_error(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The `!!! check`: `"error::mcp.MCPDeprecationWarning"` makes `old_log` fail.
Under the error filter the warning becomes the raised exception, the tool manager
wraps it, and the result is exactly the tool error the page quotes.
Under the error filter the warning becomes the raised exception, the tool wrapper treats it as a
crash, and the result plus the logged warning are exactly what the page quotes.
"""
caplog.set_level(logging.ERROR, logger="mcp.server.mcpserver.server")
async with Client(mcp) as client:
result = await client.call_tool("old_log", {})
assert result.is_error
[content] = result.content
assert isinstance(content, TextContent)
assert content.text == (
"Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577)."
)
assert content.text == "Error executing tool old_log"
(record,) = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"]
assert record.exc_info is not None and isinstance(record.exc_info[1], BaseException)
assert str(record.exc_info[1].__cause__) == "The logging capability is deprecated as of 2026-07-28 (SEP-2577)."
assert type(record.exc_info[1].__cause__).__name__ == "MCPDeprecationWarning"
async def test_filterwarnings_ignore_silences_the_whole_category() -> None:

View file

@ -1,5 +1,6 @@
"""`docs/handlers/elicitation.md`: every claim the page makes, proved against the real SDK."""
import logging
from typing import Literal
import pytest
@ -123,8 +124,7 @@ async def test_an_answer_that_does_not_match_the_schema_never_reaches_the_tool_c
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert "does not match the requested schema" in result.content[0].text
assert result.content == [TextContent(type="text", text="Error executing tool book_table")]
class Address(BaseModel):
@ -158,15 +158,21 @@ async def choose_seating(ctx: Context) -> str:
return result.data.area
async def test_a_nested_model_is_rejected_before_anything_is_sent() -> None:
"""`!!! warning`: a non-primitive field raises `TypeError` inside `ctx.elicit`, with this exact message."""
async def test_a_nested_model_is_rejected_before_anything_is_sent(caplog: pytest.LogCaptureFixture) -> None:
"""`!!! warning`: a non-primitive field raises `TypeError` inside `ctx.elicit` with this exact message,
which fails the call and lands in the server log rather than on the wire."""
caplog.set_level(logging.ERROR, logger="mcp.server.mcpserver.server")
async with Client(schema_gate_server, mode="legacy") as client:
result = await client.call_tool("sign_up", {})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == (
"Error executing tool sign_up: Elicitation schema field 'address' rendered as "
"{'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition"
assert result.content == [TextContent(type="text", text="Error executing tool sign_up")]
(record,) = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"]
assert record.exc_info is not None and record.exc_info[1] is not None
cause = record.exc_info[1].__cause__
assert isinstance(cause, TypeError)
assert str(cause) == (
"Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, "
"which is not a valid PrimitiveSchemaDefinition"
)

View file

@ -1,17 +1,19 @@
"""`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
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_a_plain_exception_becomes_a_tool_error_the_model_reads() -> None:
"""tutorial001: any non-`MCPError` exception comes back as `is_error=True` with the message in `content`."""
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
@ -21,6 +23,16 @@ async def test_a_plain_exception_becomes_a_tool_error_the_model_reads() -> None:
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:
@ -55,6 +67,21 @@ async def test_mcp_error_only_fires_on_the_raising_path() -> None:
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:
@ -69,12 +96,10 @@ async def test_resource_not_found_error_maps_to_invalid_params() -> None:
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(tutorial001.mcp, raise_exceptions=True) as client:
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: No book titled 'Nothing' in the catalog.")
]
assert result.content == [TextContent(type="text", text="Error executing tool get_author")]
async def test_a_title_the_template_knows_reads_normally() -> None:
@ -84,3 +109,14 @@ async def test_a_title_the_template_knows_reads_normally() -> None:
(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]

View file

@ -1,5 +1,7 @@
"""`docs/servers/structured-output.md`: every claim the page makes, proved against the real SDK."""
import logging
import pytest
from inline_snapshot import snapshot
from mcp_types import EmbeddedResource, ImageContent, TextContent, TextResourceContents
@ -151,15 +153,19 @@ async def test_dict_str_return_is_not_wrapped() -> None:
assert result.structured_content == {"London": 16.2, "Reykjavik": 4.4}
async def test_return_value_is_validated_against_the_schema() -> None:
"""tutorial007: a return value that does not match the output schema is a tool error, not a result."""
async def test_return_value_is_validated_against_the_schema(caplog: pytest.LogCaptureFixture) -> None:
"""tutorial007: a return value that does not match the output schema is a tool error, not a result;
the field name goes to the server log, not the client."""
caplog.set_level(logging.ERROR, logger="mcp.server.mcpserver.server")
async with Client(tutorial007.mcp) as client:
result = await client.call_tool("get_weather", {"city": "London"})
assert result.is_error
assert result.structured_content is None
assert isinstance(result.content[0], TextContent)
assert result.content[0].text.startswith("Error executing tool get_weather: 1 validation error for WeatherData")
assert "humidity\n Field required" in result.content[0].text
assert result.content == [TextContent(type="text", text="Error executing tool get_weather")]
(record,) = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"]
assert record.getMessage() == "Tool 'get_weather' raised an unexpected exception"
assert record.exc_info is not None and record.exc_info[1] is not None
assert "1 validation error for WeatherData\nhumidity\n Field required" in str(record.exc_info[1].__cause__)
async def test_structured_output_false_opts_out() -> None:

View file

@ -83,6 +83,28 @@ async def test_a_failing_tool_returns_is_error_true_instead_of_raising() -> None
]
async def test_a_crashing_tool_is_the_bare_form_with_its_traceback_in_the_server_log(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The bare `Error executing tool <name>`: the tool raised something other than ToolError, its text
is withheld, and the server log carries the exact ERROR message the page names."""
mcp = MCPServer("Weather")
@mcp.tool()
def forecast(city: str) -> str:
"""Today's forecast for one city. Crashes: the upstream table is missing the key."""
forecasts: dict[str, str] = {}
return forecasts[city]
caplog.set_level(logging.ERROR, logger="mcp.server.mcpserver.server")
async with Client(mcp) as client:
result = await client.call_tool("forecast", {"city": "Atlantis"})
assert result.content == [TextContent(type="text", text="Error executing tool forecast")]
(record,) = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"]
assert record.getMessage() == "Tool 'forecast' raised an unexpected exception"
assert record.exc_info is not None
async def test_an_unknown_tool_is_the_same_kind_of_result() -> None:
"""`Unknown tool: <name>` travels the same `is_error=True` path as a failing tool."""
async with Client(tutorial001.mcp) as client:

View file

@ -139,6 +139,19 @@ async def test_safe_join_serves_a_file_inside_the_base_directory(
assert content.text == "# Printer setup"
async def test_a_missing_manual_is_resource_not_found(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""tutorial002 and the closing tip: a path with no file behind it is `-32602` with the handler's message."""
monkeypatch.setattr(tutorial002, "DOCS_ROOT", tmp_path)
async with Client(tutorial002.mcp) as client:
with pytest.raises(MCPError) as exc:
await client.read_resource("manuals://printing/missing.md")
assert exc.value.error == ErrorData(
code=INVALID_PARAMS,
message="No manual at 'printing/missing.md'.",
data={"uri": "manuals://printing/missing.md"},
)
def test_safe_join_raises_when_the_resolved_path_escapes_the_base(tmp_path: Path) -> None:
"""tutorial002: a path that climbs out of `DOCS_ROOT` raises `PathEscapeError`."""
with pytest.raises(PathEscapeError):

View file

@ -1016,8 +1016,9 @@ REQUIREMENTS: dict[str, Requirement] = {
"mcpserver:tool:handler-throws": Requirement(
source="sdk",
behavior=(
"An exception raised by a tool function (ToolError or otherwise) is caught and returned as a "
"tool result with isError true and the failure text in content; it does not become a JSON-RPC error."
"An exception raised by a tool function is caught and returned as a tool result with isError true, "
"never a JSON-RPC error; a ToolError carries its message in content, any other exception carries only "
"the generic 'Error executing tool <name>'."
),
),
"mcpserver:tool:input-validation": Requirement(
@ -1307,8 +1308,15 @@ REQUIREMENTS: dict[str, Requirement] = {
"mcpserver:resource:read-throws-surfaced": Requirement(
source="sdk",
behavior=(
"A resource function that raises is surfaced to the caller as a JSON-RPC error response "
"(-32603 Internal error), with the original exception text withheld."
"A resource function that raises an unexpected exception is surfaced to the caller as a JSON-RPC "
"error response (-32603 Internal error), with the original exception text withheld."
),
),
"mcpserver:resource:static-not-found": Requirement(
source="sdk",
behavior=(
"A static (fixed-URI) resource function that raises ResourceNotFoundError is surfaced as -32602 "
"with the handler's message and the URI in data, the same as from a template function."
),
),
"mcpserver:resource:static": Requirement(

View file

@ -122,23 +122,25 @@ async def test_get_prompt_with_a_wrong_type_argument_is_rejected_before_the_func
"""An argument that fails the function signature's type validation is rejected before the function runs.
The decorated function is wrapped in pydantic's validate_call, so a value that cannot be
coerced to the parameter's annotation fails before the body executes. The function body
raises NotImplementedError to prove it never ran. The error is wrapped in the SDK's stable
rendering-error prefix; the body of the message is raw pydantic output and is not asserted.
coerced to the parameter's annotation fails before the body executes; `ran` stays empty to
prove it. The client sees only the SDK's rendering-error message naming the prompt, with the
pydantic detail withheld.
"""
mcp = MCPServer("prompter")
ran: list[str] = []
@mcp.prompt()
def repeat(phrase: str, count: int) -> str:
"""A registered prompt; type validation rejects the call before the function runs."""
raise NotImplementedError
ran.append(phrase) # pragma: no cover
return phrase * count # pragma: no cover
async with connect(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.get_prompt("repeat", {"phrase": "hi", "count": "many"})
assert exc_info.value.error.code == 0
assert exc_info.value.error.message.startswith("Error rendering prompt repeat: 1 validation error")
assert ran == []
assert exc_info.value.error == snapshot(ErrorData(code=0, message="Error rendering prompt repeat"))
@requirement("mcpserver:prompt:optional-args")

View file

@ -14,6 +14,7 @@ from mcp_types import (
from mcp import MCPError
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@ -152,6 +153,28 @@ async def test_resource_function_that_raises_is_surfaced_as_a_jsonrpc_error(conn
)
@requirement("mcpserver:resource:static-not-found")
async def test_static_resource_function_raising_not_found_is_invalid_params(connect: Connect) -> None:
"""ResourceNotFoundError from a fixed-URI resource function reaches the caller as -32602 with its message.
A static resource can still be absent (a report not generated yet, a file that comes and goes),
and the handler's message passes through exactly as it does from a template function.
"""
mcp = MCPServer("library")
@mcp.resource("reports://latest")
def latest() -> str:
raise ResourceNotFoundError("no report has been generated yet")
async with connect(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("reports://latest")
assert exc_info.value.error == snapshot(
ErrorData(code=-32602, message="no report has been generated yet", data={"uri": "reports://latest"})
)
@requirement("mcpserver:resource:duplicate-name")
async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first(
connect: Connect, unstamped: Unstamp

View file

@ -84,9 +84,9 @@ async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool
async def test_call_tool_function_exception_becomes_error_result(connect: Connect, unstamped: Unstamp) -> None:
"""An exception raised by a tool function is returned as an is_error result, not a JSON-RPC error.
The function's `-> str` annotation gives the tool a derived output schema, but the error
result is built before any schema validation runs, so no validation failure is layered on
top of the original exception.
The exception's own text ("boom") is withheld: an unexpected exception is a crash, and only
ToolError carries a message to the client. The function's `-> str` annotation gives the tool
a derived output schema, but the error result is built before any schema validation runs.
"""
mcp = MCPServer("errors")
@ -98,7 +98,7 @@ async def test_call_tool_function_exception_becomes_error_result(connect: Connec
result = await client.call_tool("explode", {})
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="Error executing tool explode: boom")], is_error=True)
CallToolResult(content=[TextContent(text="Error executing tool explode")], is_error=True)
)
@ -245,7 +245,7 @@ async def test_call_tool_invalid_arguments_become_error_result(connect: Connect)
@requirement("mcpserver:output-schema:server-validate")
@requirement("mcpserver:output-schema:missing-structured")
async def test_tool_with_output_schema_returning_mismatched_structured_content_is_an_error_result(
connect: Connect,
connect: Connect, unstamped: Unstamp
) -> None:
"""Structured content that fails the tool's own output schema is rejected on the server side.
@ -273,16 +273,42 @@ async def test_tool_with_output_schema_returning_mismatched_structured_content_i
mismatched_result = await client.call_tool("mismatched", {})
missing_result = await client.call_tool("missing", {})
# The body of each message is raw pydantic ValidationError output (model name, field paths,
# an errors.pydantic.dev URL) and changes across pydantic versions, so only the SDK's stable
# prefix is asserted.
assert mismatched_result.is_error is True
assert isinstance(mismatched_result.content[0], TextContent)
assert mismatched_result.content[0].text.startswith("Error executing tool mismatched: 2 validation errors")
# A return value that fails its own output schema is the tool's bug, so the pydantic detail
# goes to the server log and the client gets only the generic crash text.
assert unstamped(mismatched_result) == snapshot(
CallToolResult(content=[TextContent(text="Error executing tool mismatched")], is_error=True)
)
assert unstamped(missing_result) == snapshot(
CallToolResult(content=[TextContent(text="Error executing tool missing")], is_error=True)
)
assert missing_result.is_error is True
assert isinstance(missing_result.content[0], TextContent)
assert missing_result.content[0].text.startswith("Error executing tool missing: 1 validation error")
@requirement("mcpserver:output-schema:skip-on-error")
async def test_tool_with_output_schema_returning_is_error_result_skips_output_validation(
connect: Connect, unstamped: Unstamp
) -> None:
"""A deliberate is_error result from a tool with an output schema reaches the client as written.
The tool declares `Weather` as its output schema but returns a hand-built
`CallToolResult(is_error=True)` with no structured content. An error result has nothing to
validate, so the author's message is delivered instead of being replaced by a validation failure.
"""
mcp = MCPServer("forecaster")
class Weather(BaseModel):
temperature: float
conditions: str
@mcp.tool()
def forecast() -> Annotated[CallToolResult, Weather]:
return CallToolResult(content=[TextContent(text="Upstream is down, try again later.")], is_error=True)
async with connect(mcp) as client:
result = await client.call_tool("forecast", {})
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="Upstream is down, try again later.")], is_error=True)
)
@requirement("mcpserver:tool:duplicate-name")

View file

@ -178,7 +178,7 @@ async def test_missing_file_error(temp_file: Path):
name="test",
path=missing,
)
with pytest.raises(ValueError, match="Error reading file"):
with pytest.raises(FileNotFoundError):
await resource.read()
@ -192,7 +192,7 @@ async def test_permission_error(temp_file: Path): # pragma: lax no cover
name="test",
path=temp_file,
)
with pytest.raises(ValueError, match="Error reading file"):
with pytest.raises(PermissionError):
await resource.read()
finally:
temp_file.chmod(0o644) # Restore permissions

View file

@ -80,7 +80,7 @@ class TestFunctionResource:
@pytest.mark.anyio
async def test_error_handling(self):
"""Test error handling in FunctionResource."""
"""read() lets the function's own exception propagate; MCPServer.read_resource does the wrapping."""
def failing_func() -> str:
raise ValueError("Test error")
@ -90,8 +90,9 @@ class TestFunctionResource:
name="test",
fn=failing_func,
)
with pytest.raises(ValueError, match="Error reading resource function://test"):
with pytest.raises(ValueError) as exc:
await resource.read()
assert str(exc.value) == "Test error"
@pytest.mark.anyio
async def test_basemodel_conversion(self):
@ -258,6 +259,6 @@ async def test_read_rejects_an_input_required_result_from_a_static_function():
with pytest.raises(ValueError) as exc:
await resource.read()
assert str(exc.value) == snapshot(
"Error reading resource resource://ask: static resources cannot return "
"InputRequiredResult; only resource template functions participate in the multi-round-trip flow"
"static resources cannot return InputRequiredResult; "
"only resource template functions participate in the multi-round-trip flow"
)

View file

@ -14,6 +14,7 @@ from mcp_types import CallToolResult, ContentBlock, EmbeddedResource, InputRequi
from pydantic import BaseModel, Field, ValidationError
from typing_extensions import NotRequired, ReadOnly, Required
from mcp import MCPDeprecationWarning
from mcp.server.mcpserver import Audio, Image
from mcp.server.mcpserver.exceptions import InvalidSignature
from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata, func_metadata
@ -102,35 +103,32 @@ async def test_complex_function_runtime_arg_validation_non_json():
meta = func_metadata(complex_arguments_fn)
# Test with minimum required arguments
result = await meta.call_fn_with_arg_validation(
result = await meta.call_fn(
complex_arguments_fn,
fn_is_async=False,
arguments_to_validate={
"an_int": 1,
"must_be_none": None,
"must_be_none_dumb_annotation": None,
"list_of_ints": [1, 2, 3],
"list_str_or_str": "hello",
"an_int_annotated_with_field": 42,
"an_int_annotated_with_field_and_others": 5,
"an_int_annotated_with_junk": 100,
"unannotated": "test",
"my_model_a": {},
"my_model_a_forward_ref": {},
"my_model_b": {"how_many_shrimp": 5, "ok": {"x": 1}, "y": None},
},
arguments=meta.validate_arguments(
{
"an_int": 1,
"must_be_none": None,
"must_be_none_dumb_annotation": None,
"list_of_ints": [1, 2, 3],
"list_str_or_str": "hello",
"an_int_annotated_with_field": 42,
"an_int_annotated_with_field_and_others": 5,
"an_int_annotated_with_junk": 100,
"unannotated": "test",
"my_model_a": {},
"my_model_a_forward_ref": {},
"my_model_b": {"how_many_shrimp": 5, "ok": {"x": 1}, "y": None},
}
),
arguments_to_pass_directly=None,
)
assert result == "ok!"
# Test with invalid types
with pytest.raises(ValueError):
await meta.call_fn_with_arg_validation(
complex_arguments_fn,
fn_is_async=False,
arguments_to_validate={"an_int": "not an int"},
arguments_to_pass_directly=None,
)
# Invalid types are rejected by validate_arguments, before any call
with pytest.raises(ValidationError):
meta.validate_arguments({"an_int": "not an int"})
@pytest.mark.anyio
@ -138,31 +136,33 @@ async def test_complex_function_runtime_arg_validation_with_json():
"""Test that JSON string arguments are parsed and validated correctly"""
meta = func_metadata(complex_arguments_fn)
result = await meta.call_fn_with_arg_validation(
result = await meta.call_fn(
complex_arguments_fn,
fn_is_async=False,
arguments_to_validate={
"an_int": 1,
"must_be_none": None,
"must_be_none_dumb_annotation": None,
"list_of_ints": "[1, 2, 3]", # JSON string
"list_str_or_str": '["a", "b", "c"]', # JSON string
"an_int_annotated_with_field": 42,
"an_int_annotated_with_field_and_others": "5", # JSON string
"an_int_annotated_with_junk": 100,
"unannotated": "test",
"my_model_a": "{}", # JSON string
"my_model_a_forward_ref": "{}", # JSON string
"my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}',
},
arguments=meta.validate_arguments(
{
"an_int": 1,
"must_be_none": None,
"must_be_none_dumb_annotation": None,
"list_of_ints": "[1, 2, 3]", # JSON string
"list_str_or_str": '["a", "b", "c"]', # JSON string
"an_int_annotated_with_field": 42,
"an_int_annotated_with_field_and_others": "5", # JSON string
"an_int_annotated_with_junk": 100,
"unannotated": "test",
"my_model_a": "{}", # JSON string
"my_model_a_forward_ref": "{}", # JSON string
"my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}',
}
),
arguments_to_pass_directly=None,
)
assert result == "ok!"
@pytest.mark.anyio
async def test_call_fn_does_not_mutate_pre_validated():
"""A caller-provided `pre_validated` dict must not be mutated by the call."""
async def test_call_fn_does_not_mutate_the_arguments_dict():
"""The validated-arguments dict a caller passes to `call_fn` is not mutated when injected kwargs are merged."""
def fn(x: int, ctx: str) -> str:
return f"{x}:{ctx}"
@ -171,17 +171,34 @@ async def test_call_fn_does_not_mutate_pre_validated():
pre_validated = meta.validate_arguments({"x": 1})
snapshot = dict(pre_validated)
result = await meta.call_fn_with_arg_validation(
result = await meta.call_fn(
fn,
fn_is_async=False,
arguments_to_validate={"x": 1},
arguments=pre_validated,
arguments_to_pass_directly={"ctx": "injected"},
pre_validated=pre_validated,
)
assert result == "1:injected"
assert pre_validated == snapshot # `ctx` was not leaked into the caller's dict
@pytest.mark.anyio
async def test_call_fn_with_arg_validation_still_works_and_warns():
"""The pre-3.0 helper keeps validating-then-calling, and says it is deprecated (visible MCPDeprecationWarning)."""
def fn(x: int, ctx: str) -> str:
return f"{x}:{ctx}"
meta = func_metadata(fn, skip_names=["ctx"])
with pytest.warns(MCPDeprecationWarning, match="call_fn_with_arg_validation"):
assert await meta.call_fn_with_arg_validation(fn, False, {"x": "2"}, {"ctx": "a"}) == "2:a" # pyright: ignore[reportDeprecated]
with pytest.warns(MCPDeprecationWarning):
validated = meta.validate_arguments({"x": 3})
result = await meta.call_fn_with_arg_validation( # pyright: ignore[reportDeprecated]
fn, False, {}, {"ctx": "b"}, pre_validated=validated
)
assert result == "3:b"
def test_str_vs_list_str():
"""Test handling of string vs list[str] type annotations.
@ -209,6 +226,21 @@ def test_str_vs_list_str():
assert result["str_or_list"] == ["hello", "world"]
def test_pre_parse_json_leaves_strings_the_json_parser_refuses_untouched():
"""A string json.loads rejects with something other than JSONDecodeError (an over-long integer,
nesting past the recursion limit) is left as-is for validation to reject, not raised."""
def takes_numbers(xs: list[int]) -> None: ... # pragma: no branch
meta = func_metadata(takes_numbers)
too_long = "9" * 5000
too_deep = "[" * 200_000
assert meta.pre_parse_json({"xs": too_long}) == {"xs": too_long}
assert meta.pre_parse_json({"xs": too_deep}) == {"xs": too_deep}
with pytest.raises(ValidationError):
meta.validate_arguments({"xs": too_long})
def test_skip_names():
"""Test that skipped parameters are not included in the model"""
@ -290,10 +322,10 @@ async def test_lambda_function():
}
async def check_call(args):
return await meta.call_fn_with_arg_validation(
return await meta.call_fn(
fn,
fn_is_async=False,
arguments_to_validate=args,
arguments=meta.validate_arguments(args),
arguments_to_pass_directly=None,
)
@ -555,10 +587,10 @@ async def test_str_annotation_runtime_validation():
# Test with a JSON object string
json_payload = '{"action": "create", "resource": "user", "data": {"name": "Test User"}}'
result = await meta.call_fn_with_arg_validation(
result = await meta.call_fn(
handle_json_payload,
fn_is_async=False,
arguments_to_validate={"payload": json_payload, "strict_mode": True},
arguments=meta.validate_arguments({"payload": json_payload, "strict_mode": True}),
arguments_to_pass_directly=None,
)
@ -568,10 +600,10 @@ async def test_str_annotation_runtime_validation():
# Test with JSON array string
json_array_payload = '["task1", "task2", "task3"]'
result = await meta.call_fn_with_arg_validation(
result = await meta.call_fn(
handle_json_payload,
fn_is_async=False,
arguments_to_validate={"payload": json_array_payload},
arguments=meta.validate_arguments({"payload": json_array_payload}),
arguments_to_pass_directly=None,
)
@ -1306,17 +1338,19 @@ async def test_basemodel_reserved_names_validation():
meta = func_metadata(func_with_reserved_names)
# Test validation with reserved names
result = await meta.call_fn_with_arg_validation(
result = await meta.call_fn(
func_with_reserved_names,
fn_is_async=False,
arguments_to_validate={
"model_dump": "test_dump",
"model_validate": 42,
"dict": ["a", "b", "c"],
"json": {"key": "value"},
"validate": True,
"normal_param": "normal",
},
arguments=meta.validate_arguments(
{
"model_dump": "test_dump",
"model_validate": 42,
"dict": ["a", "b", "c"],
"json": {"key": "value"},
"validate": True,
"normal_param": "normal",
}
),
arguments_to_pass_directly=None,
)

View file

@ -1,6 +1,7 @@
"""Tests for resolver dependency injection (MRTR) on MCPServer tools."""
import json
import logging
from collections.abc import Callable
from datetime import datetime
from typing import Annotated, Any, Literal, TypeVar, cast
@ -1761,10 +1762,13 @@ def test_unevaluable_alias_and_parameterized_generics_declare_no_arm():
@pytest.mark.anyio
async def test_tool_returning_input_required_dynamically_with_resolvers_is_an_error():
async def test_tool_returning_input_required_dynamically_with_resolvers_is_an_error(
caplog: pytest.LogCaptureFixture,
):
# The annotated form of this combination is rejected at registration; a body
# that returns an InputRequiredResult without declaring it fails loudly at the
# same boundary instead of silently fighting the resolvers for the channel.
# It is an authoring bug, so it is logged as a crash rather than at INFO.
mcp = MCPServer(name="DynamicChannelClash", request_state_security=RequestStateSecurity.ephemeral())
async def lookup(ctx: Context) -> Login:
@ -1774,11 +1778,16 @@ async def test_tool_returning_input_required_dynamically_with_resolvers_is_an_er
async def sneaky(login: Annotated[Login, Resolve(lookup)]):
return InputRequiredResult(input_requests={}, request_state="opaque")
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("sneaky", {})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert "the multi-round flow is driven either by resolvers or by the tool body" in result.content[0].text
assert result.content[0].text == "Error executing tool sneaky"
(record,) = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"]
assert (record.levelname, record.getMessage()) == ("ERROR", "Tool 'sneaky' raised an unexpected exception")
assert record.exc_info is not None and record.exc_info[1] is not None
assert "the multi-round flow is driven either by resolvers or by the tool body" in str(record.exc_info[1].__cause__)
def test_question_digest_pins_the_rendered_question():

View file

@ -1,7 +1,8 @@
import base64
import logging
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from typing import Annotated, Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
@ -25,6 +26,7 @@ from mcp_types import (
ElicitRequestFormParams,
ElicitResult,
EmbeddedResource,
ErrorData,
GetPromptResult,
Icon,
ImageContent,
@ -42,17 +44,24 @@ from mcp_types import (
TextContent,
TextResourceContents,
)
from pydantic import BaseModel
from pydantic import AfterValidator, BaseModel, ValidationError
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from typing_extensions import NotRequired, TypedDict
from mcp.client import Client
from mcp.server.context import ServerRequestContext
from mcp.server.mcpserver import Context, MCPServer, ResourceSecurity
from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity, Resolve, ResourceSecurity
from mcp.server.mcpserver.exceptions import (
ResourceError,
ResourceNotFoundError,
ToolError,
UnexpectedResourceError,
UnexpectedToolError,
)
from mcp.server.mcpserver.prompts.base import Message, UserMessage
from mcp.server.mcpserver.resources import FileResource, FunctionResource
from mcp.server.mcpserver.resources import Resource as MCPServerResource
from mcp.server.mcpserver.utilities.types import Audio, Image
from mcp.server.subscriptions import (
InMemorySubscriptionBus,
@ -290,7 +299,7 @@ class TestServerTools:
assert len(result.content) == 1
content = result.content[0]
assert isinstance(content, TextContent)
assert "Test error" in content.text
assert content.text == "Error executing tool error_tool_fn"
assert result.is_error is True
async def test_tool_error_handling(self):
@ -301,7 +310,7 @@ class TestServerTools:
assert len(result.content) == 1
content = result.content[0]
assert isinstance(content, TextContent)
assert "Test error" in content.text
assert content.text == "Error executing tool error_tool_fn"
assert result.is_error is True
async def test_tool_error_details(self):
@ -313,7 +322,7 @@ class TestServerTools:
content = result.content[0]
assert isinstance(content, TextContent)
assert isinstance(content.text, str)
assert "Test error" in content.text
assert content.text == "Error executing tool error_tool_fn"
assert result.is_error is True
async def test_tool_return_value_conversion(self):
@ -1797,6 +1806,105 @@ async def test_completion_decorator() -> None:
assert result.completion.values == ["bold", "italic", "underline"]
async def test_custom_resource_returning_the_wrong_type_is_a_crash(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: a Resource subclass whose read() returns something other than str or bytes is the
server's bug, so it is logged as a crash and answered with the generic -32603, not with
'Invalid request parameters'; read_resource() called directly raises instead of returning it."""
mcp = MCPServer()
class Miscoded(MCPServerResource):
async def read(self) -> Any:
return 42
mcp.add_resource(Miscoded(uri="data://answer", name="answer"))
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.read_resource("data://answer")
assert exc.value.error == snapshot(
ErrorData(code=INTERNAL_ERROR, message="Error reading resource data://answer", data={"uri": "data://answer"})
)
assert _server_records(caplog) == snapshot(
[("ERROR", "Resource 'data://answer' raised an unexpected exception", True)]
)
cause = _logged_exception(caplog).__cause__
assert isinstance(cause, TypeError)
assert str(cause) == "Resource.read() must return str or bytes, not int"
with pytest.raises(UnexpectedResourceError):
await mcp.read_resource("data://answer")
async def test_completion_handler_crash_is_logged_and_reaches_the_client_generically(
caplog: pytest.LogCaptureFixture,
) -> None:
"""SDK-defined: a crashing completion handler is one ERROR record with its traceback, and the client
gets -32603 naming only the argument, not the exception's text."""
mcp = MCPServer()
raised = RuntimeError("index warmup failed on shard 3")
@mcp.completion()
async def complete(ref: PromptReference, argument: CompletionArgument, context: CompletionContext | None):
raise raised
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.complete(
ref=PromptReference(type="ref/prompt", name="greet"), argument={"name": "style", "value": "b"}
)
assert exc.value.error == snapshot(ErrorData(code=INTERNAL_ERROR, message="Error completing argument style"))
assert _server_records(caplog) == snapshot(
[("ERROR", "Completion for argument 'style' raised an unexpected exception", True)]
)
assert raised in _cause_chain(_logged_exception(caplog))
async def test_completion_handler_returning_the_wrong_type_is_a_crash(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: a completion handler whose return value isn't a Completion is the server's bug, so it is
logged as a crash and answered with the same generic -32603, not with 'Invalid request parameters'."""
mcp = MCPServer()
@mcp.completion()
async def complete(ref: PromptReference, argument: CompletionArgument, context: CompletionContext | None):
wrong: Any = ["bold", "italic"]
return wrong
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.complete(
ref=PromptReference(type="ref/prompt", name="greet"), argument={"name": "style", "value": "b"}
)
assert exc.value.error == snapshot(ErrorData(code=INTERNAL_ERROR, message="Error completing argument style"))
assert _server_records(caplog) == snapshot(
[("ERROR", "Completion for argument 'style' raised an unexpected exception", True)]
)
assert isinstance(_cause_chain(_logged_exception(caplog))[-1], ValidationError)
async def test_completion_handler_raising_mcp_error_passes_through(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: MCPError from a completion handler keeps its code and message and is not logged."""
mcp = MCPServer()
@mcp.completion()
async def complete(ref: PromptReference, argument: CompletionArgument, context: CompletionContext | None):
raise MCPError(code=INVALID_PARAMS, message="unknown argument")
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.complete(
ref=PromptReference(type="ref/prompt", name="greet"), argument={"name": "style", "value": "b"}
)
assert exc.value.error == snapshot(ErrorData(code=INVALID_PARAMS, message="unknown argument"))
assert _server_records(caplog) == []
def test_streamable_http_no_redirect() -> None:
"""Test that streamable HTTP routes are correctly configured."""
mcp = MCPServer()
@ -2260,6 +2368,638 @@ async def test_static_resource_raising_mcp_error_surfaces_code_and_data_to_clien
assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]}
def _cause_chain(exc: BaseException | None) -> list[BaseException]:
"""`exc` and everything it explicitly chains back to via `__cause__` (`raise ... from ...`)."""
chain: list[BaseException] = []
while exc is not None:
chain.append(exc)
exc = exc.__cause__
return chain
def _server_records(caplog: pytest.LogCaptureFixture) -> list[tuple[str, str, bool]]:
"""(level, message, has-traceback) for every record MCPServer itself wrote."""
return [
(r.levelname, r.getMessage(), r.exc_info is not None)
for r in caplog.records
if r.name == "mcp.server.mcpserver.server"
]
def _logged_exception(caplog: pytest.LogCaptureFixture) -> BaseException:
"""The exception attached to the one MCPServer record that carries a traceback."""
(exc_info,) = [r.exc_info for r in caplog.records if r.name == "mcp.server.mcpserver.server" and r.exc_info]
assert exc_info[1] is not None
return exc_info[1]
async def test_tool_raising_unexpected_exception_is_logged_once_at_error_with_its_traceback(
caplog: pytest.LogCaptureFixture,
):
"""SDK-defined: a tool crash still reaches the model as is_error, and the server logs the
original exception exactly once, at ERROR, with the traceback the result text lacks."""
mcp = MCPServer()
raised = KeyError("k")
@mcp.tool()
def lookup() -> str:
raise raised
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("lookup", {})
assert result.is_error is True
assert result.content == [TextContent(type="text", text="Error executing tool lookup")]
assert _server_records(caplog) == snapshot([("ERROR", "Tool 'lookup' raised an unexpected exception", True)])
assert raised in _cause_chain(_logged_exception(caplog))
assert len([r for r in caplog.records if r.levelno >= logging.WARNING]) == 1
async def test_tool_raising_tool_error_is_logged_at_info_without_traceback(caplog: pytest.LogCaptureFixture):
"""SDK-defined: ToolError marks an anticipated failure, so the same is_error result is
logged as one INFO record with no traceback rather than as a crash."""
mcp = MCPServer()
@mcp.tool()
def forecast(city: str) -> str:
raise ToolError(f"no forecast for {city}")
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("forecast", {"city": "Atlantis"})
assert result.is_error is True
assert result.content == [TextContent(type="text", text="Error executing tool forecast: no forecast for Atlantis")]
assert _server_records(caplog) == snapshot(
[("INFO", "Tool 'forecast' failed: 'Error executing tool forecast: no forecast for Atlantis'", False)]
)
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
async def test_tool_error_subclass_is_still_anticipated(caplog: pytest.LogCaptureFixture):
"""SDK-defined: a user's ToolError subclass is treated like ToolError - INFO, no traceback -
and reaches a programmatic caller as a plain ToolError carrying the tool-name prefix."""
mcp = MCPServer()
class QuotaExceeded(ToolError):
pass
@mcp.tool()
def spend() -> str:
raise QuotaExceeded("daily quota used up")
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("spend", {})
with pytest.raises(ToolError) as exc:
await mcp.call_tool("spend", {})
assert result.is_error is True
assert type(exc.value) is ToolError
assert str(exc.value) == snapshot("Error executing tool spend: daily quota used up")
assert _server_records(caplog) == snapshot(
[("INFO", "Tool 'spend' failed: 'Error executing tool spend: daily quota used up'", False)]
)
async def test_tool_argument_validation_failure_is_logged_at_info_without_traceback(
caplog: pytest.LogCaptureFixture,
):
"""SDK-defined: arguments the model got wrong are the model's to correct, so the rejection
is logged as one INFO record with no traceback, naming the fields but not the values."""
mcp = MCPServer()
@mcp.tool()
def add(a: int, b: int) -> int:
raise NotImplementedError
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("add", {"a": "one", "b": 2})
assert result.is_error is True
((level, message, has_traceback),) = _server_records(caplog)
assert (level, has_traceback) == ("INFO", False)
# Field names only, repr-quoted: the rejected values are the caller's data and stay out of the log.
assert message == "Tool 'add' rejected arguments: ['a']"
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
async def test_tool_argument_validation_failure_chains_directly_to_the_validation_error():
"""SDK-defined: a programmatic caller sees a plain ToolError whose `__cause__` is pydantic's
ValidationError, with no intermediate wrapper."""
mcp = MCPServer()
@mcp.tool()
def add(a: int, b: int) -> int:
raise NotImplementedError
with pytest.raises(ToolError) as exc:
await mcp.call_tool("add", {"a": "one", "b": 2})
assert type(exc.value) is ToolError
assert isinstance(exc.value.__cause__, ValidationError)
async def test_validation_error_raised_inside_the_tool_body_is_a_crash(caplog: pytest.LogCaptureFixture):
"""SDK-defined: only the SDK's own argument validation is anticipated; a pydantic
ValidationError from the tool's code is logged as a crash with its traceback."""
mcp = MCPServer()
class Row(BaseModel):
n: int
@mcp.tool()
def parse() -> str:
Row.model_validate({"n": "x"})
raise NotImplementedError
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("parse", {})
assert result.is_error is True
assert _server_records(caplog) == snapshot([("ERROR", "Tool 'parse' raised an unexpected exception", True)])
assert isinstance(_cause_chain(_logged_exception(caplog))[-1], ValidationError)
async def test_return_value_failing_the_output_schema_is_a_crash(caplog: pytest.LogCaptureFixture):
"""SDK-defined: a return value that doesn't match the declared output schema is the tool's
bug, so it is logged as a crash even though the model still gets an is_error result."""
mcp = MCPServer()
class Weather(BaseModel):
temperature: float
@mcp.tool()
def get_weather() -> Weather:
reading: Any = {"temperature": "warm"}
return reading
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("get_weather", {})
assert result.is_error is True
assert _server_records(caplog) == snapshot([("ERROR", "Tool 'get_weather' raised an unexpected exception", True)])
async def test_unknown_tool_is_logged_at_info_without_traceback(caplog: pytest.LogCaptureFixture):
"""SDK-defined: a call to a name that was never registered is the caller's mistake, logged
as one INFO record alongside the is_error result."""
mcp = MCPServer()
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("nope", {})
assert result.is_error is True
assert _server_records(caplog) == snapshot([("INFO", "Tool 'nope' failed: 'Unknown tool: nope'", False)])
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
async def test_tool_raising_mcp_error_is_not_logged_by_mcpserver(caplog: pytest.LogCaptureFixture):
"""SDK-defined: MCPError is a protocol answer the tool chose, so MCPServer writes no record for it."""
mcp = MCPServer()
@mcp.tool()
def gated() -> str:
raise MCPError(code=INVALID_PARAMS, message="not for you")
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.call_tool("gated", {})
assert exc.value.error.code == INVALID_PARAMS
assert _server_records(caplog) == []
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
async def test_resolver_raising_tool_error_is_anticipated(caplog: pytest.LogCaptureFixture):
"""SDK-defined: a ToolError from a Resolve() resolver is classified like one from the tool
body - INFO, no traceback."""
mcp = MCPServer(name="resolvers", request_state_security=RequestStateSecurity.ephemeral())
async def current_user(ctx: Context) -> str:
raise ToolError("sign in first")
@mcp.tool()
async def whoami(user: Annotated[str, Resolve(current_user)]) -> str:
raise NotImplementedError
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("whoami", {})
assert result.content == [TextContent(type="text", text="Error executing tool whoami: sign in first")]
assert _server_records(caplog) == snapshot(
[("INFO", "Tool 'whoami' failed: 'Error executing tool whoami: sign in first'", False)]
)
async def test_resolver_crash_is_logged_as_the_tools_crash(caplog: pytest.LogCaptureFixture):
"""SDK-defined: an unexpected exception in a Resolve() resolver is the tool's crash - ERROR
with a traceback reaching the resolver's exception."""
mcp = MCPServer(name="resolvers", request_state_security=RequestStateSecurity.ephemeral())
raised = ConnectionError("user directory unreachable")
async def current_user(ctx: Context) -> str:
raise raised
@mcp.tool()
async def whoami(user: Annotated[str, Resolve(current_user)]) -> str:
raise NotImplementedError
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("whoami", {})
assert result.is_error is True
assert _server_records(caplog) == snapshot([("ERROR", "Tool 'whoami' raised an unexpected exception", True)])
assert raised in _cause_chain(_logged_exception(caplog))
async def test_argument_validator_that_crashes_is_the_tools_crash(caplog: pytest.LogCaptureFixture):
"""SDK-defined: pydantic only turns ValueError/AssertionError into ValidationError, so a validator
raising anything else is a bug in the tool's schema and is wrapped and logged as a crash."""
mcp = MCPServer()
raised = TypeError("codes are compared as integers")
def check(code: str) -> str:
raise raised
@mcp.tool()
def redeem(code: Annotated[str, AfterValidator(check)]) -> str:
raise NotImplementedError
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("redeem", {"code": "SAVE10"})
with pytest.raises(UnexpectedToolError) as exc:
await mcp.call_tool("redeem", {"code": "SAVE10"})
assert result.content == [TextContent(type="text", text="Error executing tool redeem")]
assert exc.value.__cause__ is raised
assert _server_records(caplog) == snapshot([("ERROR", "Tool 'redeem' raised an unexpected exception", True)])
async def test_argument_validator_raising_mcp_error_is_a_protocol_error(caplog: pytest.LogCaptureFixture):
"""SDK-defined: MCPError keeps its meaning wherever it is raised, including inside an argument
validator: the request fails with that code and MCPServer logs nothing."""
mcp = MCPServer()
def check(code: str) -> str:
raise MCPError(code=INVALID_PARAMS, message="codes are issued per session")
@mcp.tool()
def redeem(code: Annotated[str, AfterValidator(check)]) -> str:
raise NotImplementedError
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.call_tool("redeem", {"code": "SAVE10"})
assert exc.value.error == snapshot(ErrorData(code=INVALID_PARAMS, message="codes are issued per session"))
assert _server_records(caplog) == []
async def test_resource_error_escaping_a_tool_is_anticipated(caplog: pytest.LogCaptureFixture):
"""SDK-defined: a tool that lets ResourceNotFoundError from ctx.read_resource() propagate has
reported an anticipated failure, so it is INFO here just as it is for resources/read."""
mcp = MCPServer()
@mcp.resource("books://{title}")
def book(title: str) -> str:
raise ResourceNotFoundError(f"No book titled {title!r}.")
@mcp.tool()
async def summarise(title: str, ctx: Context) -> str:
await ctx.read_resource(f"books://{title}")
raise NotImplementedError
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("summarise", {"title": "Nothing"})
with pytest.raises(ToolError) as exc:
await mcp.call_tool("summarise", {"title": "Nothing"})
assert result.content == [
TextContent(type="text", text="Error executing tool summarise: No book titled 'Nothing'.")
]
assert type(exc.value) is ToolError
assert _server_records(caplog) == snapshot(
[("INFO", "Tool 'summarise' failed: \"Error executing tool summarise: No book titled 'Nothing'.\"", False)]
)
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
async def test_resource_crash_escaping_a_tool_is_the_tools_crash(caplog: pytest.LogCaptureFixture):
"""SDK-defined: a crashing resource read inside a tool stays a crash under the tool's name, logged
once, with the traceback reaching the resource function's own exception."""
mcp = MCPServer()
raised = ConnectionError("catalog database unreachable")
@mcp.resource("books://{title}")
def book(title: str) -> str:
raise raised
@mcp.tool()
async def summarise(title: str, ctx: Context) -> str:
await ctx.read_resource(f"books://{title}")
raise NotImplementedError
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("summarise", {"title": "Dune"})
assert result.content == [
TextContent(
type="text",
text="Error executing tool summarise: Error creating resource from template books://Dune",
)
]
assert _server_records(caplog) == snapshot([("ERROR", "Tool 'summarise' raised an unexpected exception", True)])
assert raised in _cause_chain(_logged_exception(caplog))
async def test_tool_that_recovers_from_a_missing_resource_logs_nothing(caplog: pytest.LogCaptureFixture):
"""SDK-defined: MCPServer.read_resource() itself writes no record, so a tool that catches
ResourceNotFoundError and carries on leaves the log clean."""
mcp = MCPServer()
@mcp.resource("books://{title}")
def book(title: str) -> str:
raise ResourceNotFoundError(f"No book titled {title!r}.")
@mcp.tool()
async def summarise(title: str, ctx: Context) -> str:
try:
await ctx.read_resource(f"books://{title}")
except ResourceNotFoundError:
return "not in the catalog"
raise NotImplementedError
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("summarise", {"title": "Nothing"})
assert result.content == [TextContent(type="text", text="not in the catalog")]
assert _server_records(caplog) == []
async def test_static_resource_raising_unexpected_exception_is_logged_once_at_error_with_its_traceback(
caplog: pytest.LogCaptureFixture,
):
"""SDK-defined: the client gets a -32603 naming only the URI, and the withheld original is
logged exactly once, at ERROR, with its traceback."""
mcp = MCPServer()
raised = RuntimeError("connection pool exhausted")
@mcp.resource("db://stats")
def stats() -> str:
raise raised
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.read_resource("db://stats")
assert exc.value.error == snapshot(
ErrorData(code=INTERNAL_ERROR, message="Error reading resource db://stats", data={"uri": "db://stats"})
)
assert _server_records(caplog) == snapshot(
[("ERROR", "Resource 'db://stats' raised an unexpected exception", True)]
)
assert raised in _cause_chain(_logged_exception(caplog))
assert len([r for r in caplog.records if r.levelno >= logging.WARNING]) == 1
async def test_resource_template_raising_unexpected_exception_is_logged_once_at_error_with_its_traceback(
caplog: pytest.LogCaptureFixture,
):
"""SDK-defined: a template handler crash surfaces as -32603 naming only the URI, and the
withheld original is logged exactly once, at ERROR, with its traceback."""
mcp = MCPServer()
raised = RuntimeError("connection pool exhausted")
@mcp.resource("db://tables/{table}")
def describe(table: str) -> str:
raise raised
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.read_resource("db://tables/users")
assert exc.value.error == snapshot(
ErrorData(
code=INTERNAL_ERROR,
message="Error creating resource from template db://tables/users",
data={"uri": "db://tables/users"},
)
)
assert _server_records(caplog) == snapshot(
[("ERROR", "Resource 'db://tables/users' raised an unexpected exception", True)]
)
assert raised in _cause_chain(_logged_exception(caplog))
assert len([r for r in caplog.records if r.levelno >= logging.WARNING]) == 1
async def test_read_resource_wraps_a_crash_as_unexpected_resource_error_chained_to_the_original():
"""SDK-defined: for static and template resources alike, a programmatic caller gets
UnexpectedResourceError naming only the URI, with `__cause__` the handler's own exception."""
mcp = MCPServer()
raised = RuntimeError("connection pool exhausted")
@mcp.resource("db://stats")
def stats() -> str:
raise raised
@mcp.resource("db://tables/{table}")
def describe(table: str) -> str:
raise raised
with pytest.raises(UnexpectedResourceError) as static:
await mcp.read_resource("db://stats")
with pytest.raises(UnexpectedResourceError) as template:
await mcp.read_resource("db://tables/users")
assert str(static.value) == snapshot("Error reading resource db://stats")
assert static.value.__cause__ is raised
assert str(template.value) == snapshot("Error creating resource from template db://tables/users")
assert template.value.__cause__ is raised
async def test_custom_resource_subclass_crash_is_wrapped_and_logged_like_a_function_resource(
caplog: pytest.LogCaptureFixture,
):
"""SDK-defined: a hand-written Resource subclass whose read() raises gets the same treatment as
a decorated function - -32603 naming only the URI, one ERROR record chaining to the original."""
raised = OSError("sensor bus offline")
class SensorResource(MCPServerResource):
async def read(self) -> str:
raise raised
mcp = MCPServer()
mcp.add_resource(SensorResource(uri="sensor://temp", name="temp"))
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.read_resource("sensor://temp")
assert exc.value.error == snapshot(
ErrorData(code=INTERNAL_ERROR, message="Error reading resource sensor://temp", data={"uri": "sensor://temp"})
)
assert _server_records(caplog) == snapshot(
[("ERROR", "Resource 'sensor://temp' raised an unexpected exception", True)]
)
logged = _logged_exception(caplog)
assert isinstance(logged, UnexpectedResourceError) and logged.__cause__ is raised
async def test_static_resource_raising_resource_not_found_error_is_invalid_params_logged_at_info(
caplog: pytest.LogCaptureFixture,
):
"""SDK-defined: ResourceNotFoundError from a static resource handler passes through as -32602
with the handler's message, as it does from a template handler, and is logged at INFO."""
mcp = MCPServer()
@mcp.resource("reports://latest")
def latest() -> str:
raise ResourceNotFoundError("no report has been generated yet")
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.read_resource("reports://latest")
assert exc.value.error == snapshot(
ErrorData(code=INVALID_PARAMS, message="no report has been generated yet", data={"uri": "reports://latest"})
)
assert _server_records(caplog) == snapshot(
[("INFO", "Resource 'reports://latest' failed: 'no report has been generated yet'", False)]
)
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
async def test_deliberate_resource_error_passes_its_message_through_and_is_logged_at_info(
caplog: pytest.LogCaptureFixture,
):
"""SDK-defined: a ResourceError the handler raised on purpose reaches the client as -32603 with
the handler's message, from a static resource as from a template, and is one INFO record each."""
mcp = MCPServer()
@mcp.resource("db://stats")
def stats() -> str:
raise ResourceError("stats database is in maintenance")
@mcp.resource("db://tables/{table}")
def describe(table: str) -> str:
raise ResourceError(f"table {table} is being rebuilt")
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
with pytest.raises(MCPError) as static:
await client.read_resource("db://stats")
with pytest.raises(MCPError) as template:
await client.read_resource("db://tables/users")
assert static.value.error == snapshot(
ErrorData(code=INTERNAL_ERROR, message="stats database is in maintenance", data={"uri": "db://stats"})
)
assert template.value.error == snapshot(
ErrorData(code=INTERNAL_ERROR, message="table users is being rebuilt", data={"uri": "db://tables/users"})
)
assert _server_records(caplog) == snapshot(
[
("INFO", "Resource 'db://stats' failed: 'stats database is in maintenance'", False),
("INFO", "Resource 'db://tables/users' failed: 'table users is being rebuilt'", False),
]
)
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
async def test_prompt_raising_unexpected_exception_is_logged_once(caplog: pytest.LogCaptureFixture):
"""SDK-defined: a prompt crash is logged exactly once, by the dispatcher boundary that turns it
into the JSON-RPC error, and not a second time by MCPServer."""
mcp = MCPServer()
raised = RuntimeError("template store unreachable")
@mcp.prompt()
def briefing() -> str:
raise raised
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc:
await client.get_prompt("briefing")
assert exc.value.error.code == INTERNAL_ERROR
assert _server_records(caplog) == []
(record,) = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert record.levelno == logging.ERROR
assert record.exc_info is not None and raised in _cause_chain(record.exc_info[1])
async def test_call_tool_wraps_a_crash_as_unexpected_tool_error_chained_to_the_original():
"""SDK-defined: programmatic callers can tell a crash from a deliberate ToolError by type and
reach the original exception through `__cause__`."""
mcp = MCPServer()
raised = RuntimeError("boom")
@mcp.tool()
def explode() -> str:
raise raised
with pytest.raises(UnexpectedToolError) as exc:
await mcp.call_tool("explode", {})
assert str(exc.value) == snapshot("Error executing tool explode")
assert exc.value.__cause__ is raised
async def test_call_tool_keeps_a_deliberate_tool_error_a_plain_tool_error():
"""SDK-defined: a ToolError raised by the tool is re-raised as a plain ToolError carrying the
tool-name prefix, never reclassified as unexpected."""
mcp = MCPServer()
@mcp.tool()
def refuse() -> str:
raise ToolError("not today")
with pytest.raises(ToolError) as exc:
await mcp.call_tool("refuse", {})
assert type(exc.value) is ToolError
assert str(exc.value) == snapshot("Error executing tool refuse: not today")
async def test_nested_tool_crash_stays_unexpected_through_the_outer_tool(caplog: pytest.LogCaptureFixture):
"""SDK-defined: when a tool awaits another tool that crashes, the outer wrapper keeps the
UnexpectedToolError classification, so the crash is still logged once with its traceback."""
mcp = MCPServer()
raised = ZeroDivisionError("division by zero")
@mcp.tool()
def inner() -> str:
raise raised
@mcp.tool()
async def outer(ctx: Context) -> str:
await ctx.mcp_server.call_tool("inner", {})
raise NotImplementedError
caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("outer", {})
assert result.content == [TextContent(type="text", text="Error executing tool outer: Error executing tool inner")]
assert _server_records(caplog) == snapshot([("ERROR", "Tool 'outer' raised an unexpected exception", True)])
assert raised in _cause_chain(_logged_exception(caplog))
async def test_context_exposes_client_capabilities_from_connection():
mcp = MCPServer()
seen: list[ClientCapabilities | None] = []

View file

@ -106,4 +106,4 @@ async def test_normal_exceptions_still_return_error_result():
assert result.is_error is True
assert len(result.content) == 1
assert isinstance(result.content[0], types.TextContent)
assert "Something went wrong" in result.content[0].text
assert result.content[0].text == "Error executing tool failing_tool"