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:
parent
f1c40b062f
commit
56af44727b
4 changed files with 1427 additions and 310 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue