Log MCPServer handler exceptions by kind and keep crash details off the wire (#3314)

Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
This commit is contained in:
Max 2026-08-24 15:51:18 +01:00 • committed by GitHub
commit 56af44727b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -14,6 +14,7 @@ from mcp_types import CallToolResult, ContentBlock, EmbeddedResource, InputRequi
from pydantic import BaseModel, Field, ValidationError
from typing_extensions import NotRequired, ReadOnly, Required
from mcp import MCPDeprecationWarning
from mcp.server.mcpserver import Audio, Image
from mcp.server.mcpserver.exceptions import InvalidSignature
from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata, func_metadata
@ -102,35 +103,32 @@ async def test_complex_function_runtime_arg_validation_non_json():
meta = func_metadata(complex_arguments_fn)
# Test with minimum required arguments
result = await meta.call_fn_with_arg_validation(
result = await meta.call_fn(
complex_arguments_fn,
fn_is_async=False,
arguments_to_validate={
"an_int": 1,
"must_be_none": None,
"must_be_none_dumb_annotation": None,
"list_of_ints": [1, 2, 3],
"list_str_or_str": "hello",
"an_int_annotated_with_field": 42,
"an_int_annotated_with_field_and_others": 5,
"an_int_annotated_with_junk": 100,
"unannotated": "test",
"my_model_a": {},
"my_model_a_forward_ref": {},
"my_model_b": {"how_many_shrimp": 5, "ok": {"x": 1}, "y": None},
},
arguments=meta.validate_arguments(
{
"an_int": 1,
"must_be_none": None,
"must_be_none_dumb_annotation": None,
"list_of_ints": [1, 2, 3],
"list_str_or_str": "hello",
"an_int_annotated_with_field": 42,
"an_int_annotated_with_field_and_others": 5,
"an_int_annotated_with_junk": 100,
"unannotated": "test",
"my_model_a": {},
"my_model_a_forward_ref": {},
"my_model_b": {"how_many_shrimp": 5, "ok": {"x": 1}, "y": None},
}
),
arguments_to_pass_directly=None,
)
assert result == "ok!"
# Test with invalid types
with pytest.raises(ValueError):
await meta.call_fn_with_arg_validation(
complex_arguments_fn,
fn_is_async=False,
arguments_to_validate={"an_int": "not an int"},
arguments_to_pass_directly=None,
)
# Invalid types are rejected by validate_arguments, before any call
with pytest.raises(ValidationError):
meta.validate_arguments({"an_int": "not an int"})
@pytest.mark.anyio
@ -138,31 +136,33 @@ async def test_complex_function_runtime_arg_validation_with_json():
"""Test that JSON string arguments are parsed and validated correctly"""
meta = func_metadata(complex_arguments_fn)
result = await meta.call_fn_with_arg_validation(
result = await meta.call_fn(
complex_arguments_fn,
fn_is_async=False,
arguments_to_validate={
"an_int": 1,
"must_be_none": None,
"must_be_none_dumb_annotation": None,
"list_of_ints": "[1, 2, 3]", # JSON string
"list_str_or_str": '["a", "b", "c"]', # JSON string
"an_int_annotated_with_field": 42,
"an_int_annotated_with_field_and_others": "5", # JSON string
"an_int_annotated_with_junk": 100,
"unannotated": "test",
"my_model_a": "{}", # JSON string
"my_model_a_forward_ref": "{}", # JSON string
"my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}',
},
arguments=meta.validate_arguments(
{
"an_int": 1,
"must_be_none": None,
"must_be_none_dumb_annotation": None,
"list_of_ints": "[1, 2, 3]", # JSON string
"list_str_or_str": '["a", "b", "c"]', # JSON string
"an_int_annotated_with_field": 42,
"an_int_annotated_with_field_and_others": "5", # JSON string
"an_int_annotated_with_junk": 100,
"unannotated": "test",
"my_model_a": "{}", # JSON string
"my_model_a_forward_ref": "{}", # JSON string
"my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}',
}
),
arguments_to_pass_directly=None,
)
assert result == "ok!"
@pytest.mark.anyio
async def test_call_fn_does_not_mutate_pre_validated():
"""A caller-provided `pre_validated` dict must not be mutated by the call."""
async def test_call_fn_does_not_mutate_the_arguments_dict():
"""The validated-arguments dict a caller passes to `call_fn` is not mutated when injected kwargs are merged."""
def fn(x: int, ctx: str) -> str:
return f"{x}:{ctx}"
@ -171,17 +171,34 @@ async def test_call_fn_does_not_mutate_pre_validated():
pre_validated = meta.validate_arguments({"x": 1})
snapshot = dict(pre_validated)
result = await meta.call_fn_with_arg_validation(
result = await meta.call_fn(
fn,
fn_is_async=False,
arguments_to_validate={"x": 1},
arguments=pre_validated,
arguments_to_pass_directly={"ctx": "injected"},
pre_validated=pre_validated,
)
assert result == "1:injected"
assert pre_validated == snapshot # `ctx` was not leaked into the caller's dict
@pytest.mark.anyio
async def test_call_fn_with_arg_validation_still_works_and_warns():
"""The pre-3.0 helper keeps validating-then-calling, and says it is deprecated (visible MCPDeprecationWarning)."""
def fn(x: int, ctx: str) -> str:
return f"{x}:{ctx}"
meta = func_metadata(fn, skip_names=["ctx"])
with pytest.warns(MCPDeprecationWarning, match="call_fn_with_arg_validation"):
assert await meta.call_fn_with_arg_validation(fn, False, {"x": "2"}, {"ctx": "a"}) == "2:a" # pyright: ignore[reportDeprecated]
with pytest.warns(MCPDeprecationWarning):
validated = meta.validate_arguments({"x": 3})
result = await meta.call_fn_with_arg_validation( # pyright: ignore[reportDeprecated]
fn, False, {}, {"ctx": "b"}, pre_validated=validated
)
assert result == "3:b"
def test_str_vs_list_str():
"""Test handling of string vs list[str] type annotations.
@ -209,6 +226,21 @@ def test_str_vs_list_str():
assert result["str_or_list"] == ["hello", "world"]
def test_pre_parse_json_leaves_strings_the_json_parser_refuses_untouched():
"""A string json.loads rejects with something other than JSONDecodeError (an over-long integer,
nesting past the recursion limit) is left as-is for validation to reject, not raised."""
def takes_numbers(xs: list[int]) -> None: ... # pragma: no branch
meta = func_metadata(takes_numbers)
too_long = "9" * 5000
too_deep = "[" * 200_000
assert meta.pre_parse_json({"xs": too_long}) == {"xs": too_long}
assert meta.pre_parse_json({"xs": too_deep}) == {"xs": too_deep}
with pytest.raises(ValidationError):
meta.validate_arguments({"xs": too_long})
def test_skip_names():
"""Test that skipped parameters are not included in the model"""
@ -290,10 +322,10 @@ async def test_lambda_function():
}
async def check_call(args):
return await meta.call_fn_with_arg_validation(
return await meta.call_fn(
fn,
fn_is_async=False,
arguments_to_validate=args,
arguments=meta.validate_arguments(args),
arguments_to_pass_directly=None,
)
@ -555,10 +587,10 @@ async def test_str_annotation_runtime_validation():
# Test with a JSON object string
json_payload = '{"action": "create", "resource": "user", "data": {"name": "Test User"}}'
result = await meta.call_fn_with_arg_validation(
result = await meta.call_fn(
handle_json_payload,
fn_is_async=False,
arguments_to_validate={"payload": json_payload, "strict_mode": True},
arguments=meta.validate_arguments({"payload": json_payload, "strict_mode": True}),
arguments_to_pass_directly=None,
)
@ -568,10 +600,10 @@ async def test_str_annotation_runtime_validation():
# Test with JSON array string
json_array_payload = '["task1", "task2", "task3"]'
result = await meta.call_fn_with_arg_validation(
result = await meta.call_fn(
handle_json_payload,
fn_is_async=False,
arguments_to_validate={"payload": json_array_payload},
arguments=meta.validate_arguments({"payload": json_array_payload}),
arguments_to_pass_directly=None,
)
@ -1306,17 +1338,19 @@ async def test_basemodel_reserved_names_validation():
meta = func_metadata(func_with_reserved_names)
# Test validation with reserved names
result = await meta.call_fn_with_arg_validation(
result = await meta.call_fn(
func_with_reserved_names,
fn_is_async=False,
arguments_to_validate={
"model_dump": "test_dump",
"model_validate": 42,
"dict": ["a", "b", "c"],
"json": {"key": "value"},
"validate": True,
"normal_param": "normal",
},
arguments=meta.validate_arguments(
{
"model_dump": "test_dump",
"model_validate": 42,
"dict": ["a", "b", "c"],
"json": {"key": "value"},
"validate": True,
"normal_param": "normal",
}
),
arguments_to_pass_directly=None,
)