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
6 changed files with 1427 additions and 310 deletions

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"