60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
126 lines
4.1 KiB
Python
126 lines
4.1 KiB
Python
"""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}
|