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

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")