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
17 changed files with 1427 additions and 310 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue