"""Tests for distinguishing argument-validation errors from tool-body errors. See https://github.com/PrefectHQ/fastmcp/issues/4128: a bad call (invalid arguments) should surface as fastmcp's ``ValidationError`` so downstream error taxonomy (middleware, Sentry filters) can treat it as a client error, while a ``pydantic.ValidationError`` raised by the tool's own body is a server-side bug and must propagate unchanged. """ from typing import Annotated, Any import pytest from pydantic import BaseModel, Field from pydantic import ValidationError as PydanticValidationError from fastmcp.exceptions import ValidationError from fastmcp.tools.base import Tool class _Inner(BaseModel): x: int class TestArgumentValidationErrors: """Invalid arguments are converted to fastmcp's ValidationError.""" async def test_async_constraint_violation(self): async def tool_fn(n: Annotated[int, Field(le=10)]) -> int: return n tool = Tool.from_function(tool_fn) with pytest.raises(ValidationError): await tool.run({"n": 20}) async def test_sync_constraint_violation(self): def tool_fn(n: Annotated[int, Field(le=10)]) -> int: return n tool = Tool.from_function(tool_fn) with pytest.raises(ValidationError): await tool.run({"n": 20}) async def test_wrong_type_is_argument_error(self): async def tool_fn(n: int) -> int: return n tool = Tool.from_function(tool_fn) with pytest.raises(ValidationError): await tool.run({"n": "not-an-int"}) async def test_missing_required_argument(self): async def tool_fn(n: int) -> int: return n tool = Tool.from_function(tool_fn) with pytest.raises(ValidationError): await tool.run({}) class TestToolBodyErrors: """A pydantic error raised by the body is NOT reclassified as a bad call.""" async def test_async_body_pydantic_error_propagates(self): async def tool_fn(data: str) -> int: bad_value: Any = "not-an-int" _Inner(x=bad_value) # raises a pydantic ValidationError from the body return 1 tool = Tool.from_function(tool_fn) with pytest.raises(PydanticValidationError): await tool.run({"data": "valid"}) # And it must not be fastmcp's ValidationError. with pytest.raises(PydanticValidationError) as exc_info: await tool.run({"data": "valid"}) assert not isinstance(exc_info.value, ValidationError) async def test_sync_body_pydantic_error_propagates(self): def tool_fn(data: str) -> int: bad_value: Any = "not-an-int" _Inner(x=bad_value) # raises a pydantic ValidationError from the body return 1 tool = Tool.from_function(tool_fn) with pytest.raises(PydanticValidationError): await tool.run({"data": "valid"}) class TestTaskArgumentValidation: """The task-execution path (coerce_task_arguments) converts arg errors too.""" def test_coerce_task_arguments_wrong_type(self): def tool_fn(n: int) -> int: return n tool = Tool.from_function(tool_fn) with pytest.raises(ValidationError): tool.coerce_task_arguments({"n": "not-an-int"}) def test_coerce_task_arguments_constraint_violation(self): def tool_fn(n: Annotated[int, Field(le=10)]) -> int: return n tool = Tool.from_function(tool_fn) with pytest.raises(ValidationError): tool.coerce_task_arguments({"n": 20}) class TestValidCallsStillWork: """Regression: the happy path is unaffected.""" async def test_async_valid_call(self): async def tool_fn(n: Annotated[int, Field(le=10)]) -> int: return n * 2 tool = Tool.from_function(tool_fn) result = await tool.run({"n": 5}) assert result.structured_content == {"result": 10} async def test_sync_valid_call(self): def tool_fn(n: int) -> int: return n + 1 tool = Tool.from_function(tool_fn) result = await tool.run({"n": 5}) assert result.structured_content == {"result": 6}