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