From e036cba383a199a11974752155ea5b8c6b3a0cf5 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Tue, 14 Oct 2025 14:49:30 -0400
Subject: [PATCH] Add Pydantic-compatible input validation (#2073)
---
docs/servers/server.mdx | 11 +-
docs/servers/tools.mdx | 567 +++++++----------------
src/fastmcp/server/server.py | 10 +-
src/fastmcp/settings.py | 24 +-
src/fastmcp/tools/tool_manager.py | 4 +
tests/client/test_client.py | 3 +-
tests/server/test_input_validation.py | 353 ++++++++++++++
tests/server/test_server_interactions.py | 66 ++-
tests/tools/test_tool_manager.py | 4 +-
9 files changed, 581 insertions(+), 461 deletions(-)
create mode 100644 tests/server/test_input_validation.py
diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx
index 1fc903659..f4a5f1ae6 100644
--- a/docs/servers/server.mdx
+++ b/docs/servers/server.mdx
@@ -73,9 +73,15 @@ The `FastMCP` constructor accepts several arguments:
How to handle duplicate prompt registrations
+
+
+
+ Controls how tool input parameters are validated. When `False` (default), FastMCP uses Pydantic's flexible validation that coerces compatible inputs (e.g., `"10"` → `10` for int parameters). When `True`, uses the MCP SDK's JSON Schema validation to validate inputs against the exact schema before passing them to your function, rejecting any type mismatches. The default mode improves compatibility with LLM clients while maintaining type safety. See [Input Validation Modes](/servers/tools#input-validation-modes) for details
+
+
-
+
Whether to include FastMCP metadata in component responses. When `True`, component tags and other FastMCP-specific metadata are included in the `_fastmcp` namespace within each component's `meta` field. When `False`, this metadata is omitted, resulting in cleaner integration with external systems. Can be overridden globally via `FASTMCP_INCLUDE_FASTMCP_META` environment variable
@@ -336,6 +342,7 @@ import fastmcp
print(fastmcp.settings.log_level) # Default: "INFO"
print(fastmcp.settings.mask_error_details) # Default: False
print(fastmcp.settings.resource_prefix_format) # Default: "path"
+print(fastmcp.settings.strict_input_validation) # Default: False
print(fastmcp.settings.include_fastmcp_meta) # Default: True
```
@@ -343,6 +350,7 @@ Common global settings include:
- **`log_level`**: Logging level ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"), set with `FASTMCP_LOG_LEVEL`
- **`mask_error_details`**: Whether to hide detailed error information from clients, set with `FASTMCP_MASK_ERROR_DETAILS`
- **`resource_prefix_format`**: How to format resource prefixes ("path" or "protocol"), set with `FASTMCP_RESOURCE_PREFIX_FORMAT`
+- **`strict_input_validation`**: Controls tool input validation mode (default: False for flexible coercion), set with `FASTMCP_STRICT_INPUT_VALIDATION`. See [Input Validation Modes](/servers/tools#input-validation-modes)
- **`include_fastmcp_meta`**: Whether to include FastMCP metadata in component responses (default: True), set with `FASTMCP_INCLUDE_FASTMCP_META`
- **`env_file`**: Path to the environment file to load settings from (default: ".env"), set with `FASTMCP_ENV_FILE`. Useful when your project uses a `.env` file with syntax incompatible with python-dotenv
@@ -376,6 +384,7 @@ Global FastMCP settings can be configured via environment variables (prefixed wi
export FASTMCP_LOG_LEVEL=DEBUG
export FASTMCP_MASK_ERROR_DETAILS=True
export FASTMCP_RESOURCE_PREFIX_FORMAT=protocol
+export FASTMCP_STRICT_INPUT_VALIDATION=False
export FASTMCP_INCLUDE_FASTMCP_META=False
```
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index 8da84c202..c9e73cfb8 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -9,8 +9,6 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
Tools are the core building blocks that allow your LLM to interact with external systems, execute code, and access data that isn't in its training data. In FastMCP, tools are Python functions exposed to LLMs through the MCP protocol.
-## What Are Tools?
-
Tools in FastMCP transform regular Python functions into capabilities that LLMs can invoke during conversations. When an LLM decides to use a tool:
1. It sends a request with parameters based on the tool's schema.
@@ -20,9 +18,8 @@ Tools in FastMCP transform regular Python functions into capabilities that LLMs
This allows LLMs to perform tasks like querying databases, calling APIs, making calculations, or accessing files—extending their capabilities beyond what's in their training data.
-## Tools
-### The `@tool` Decorator
+## The `@tool` Decorator
Creating a tool is as simple as decorating a Python function with `@mcp.tool`:
@@ -49,7 +46,7 @@ The way you define your Python function dictates how the tool appears and behave
Functions with `*args` or `**kwargs` are not supported as tools. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists.
-#### Decorator Arguments
+### Decorator Arguments
While FastMCP infers the name and description from your function, you can override these and add additional metadata using arguments to the `@mcp.tool` decorator:
@@ -117,7 +114,7 @@ def search_products_implementation(query: str, category: str | None = None) -> l
-### Async and Synchronous Tools
+### Async Support
FastMCP is an async-first framework that seamlessly supports both asynchronous (`async def`) and synchronous (`def`) functions as tools. Async tools are preferred for I/O-bound operations to keep your server responsive.
@@ -170,15 +167,13 @@ def my_tool() -> None:
```
+## Arguments
+
+By default, FastMCP converts Python functions into MCP tools by inspecting the function's signature and type annotations. This allows you to use standard Python type annotations for your tools. In general, the framework strives to "just work": idiomatic Python behaviors like parameter defaults and type annotations are automatically translated into MCP schemas. However, there are a number of ways to customize the behavior of your tools.
### Type Annotations
-Type annotations for parameters are essential for proper tool functionality. They:
-1. Inform the LLM about the expected data types for each parameter
-2. Enable FastMCP to validate input data from clients
-3. Generate accurate JSON schemas for the MCP protocol
-
-Use standard Python type annotations for parameters:
+MCP tools have typed arguments, and FastMCP uses type annotations to determine those types. Therefore, you should use standard Python type annotations for tool arguments:
```python
@mcp.tool
@@ -195,18 +190,83 @@ FastMCP supports a wide range of type annotations, including all Pydantic types:
| Type Annotation | Example | Description |
| :---------------------- | :---------------------------- | :---------------------------------- |
-| Basic types | `int`, `float`, `str`, `bool` | Simple scalar values - see [Built-in Types](#built-in-types) |
-| Binary data | `bytes` | Binary content - see [Binary Data](#binary-data) |
-| Date and Time | `datetime`, `date`, `timedelta` | Date and time objects - see [Date and Time Types](#date-and-time-types) |
-| Collection types | `list[str]`, `dict[str, int]`, `set[int]` | Collections of items - see [Collection Types](#collection-types) |
-| Optional types | `float \| None`, `Optional[float]`| Parameters that may be null/omitted - see [Union and Optional Types](#union-and-optional-types) |
-| Union types | `str \| int`, `Union[str, int]`| Parameters accepting multiple types - see [Union and Optional Types](#union-and-optional-types) |
-| Constrained types | `Literal["A", "B"]`, `Enum` | Parameters with specific allowed values - see [Constrained Types](#constrained-types) |
-| Paths | `Path` | File system paths - see [Paths](#paths) |
-| UUIDs | `UUID` | Universally unique identifiers - see [UUIDs](#uuids) |
-| Pydantic models | `UserData` | Complex structured data - see [Pydantic Models](#pydantic-models) |
+| Basic types | `int`, `float`, `str`, `bool` | Simple scalar values |
+| Binary data | `bytes` | Binary content (raw strings, not auto-decoded base64) |
+| Date and Time | `datetime`, `date`, `timedelta` | Date and time objects (ISO format strings) |
+| Collection types | `list[str]`, `dict[str, int]`, `set[int]` | Collections of items |
+| Optional types | `float \| None`, `Optional[float]`| Parameters that may be null/omitted |
+| Union types | `str \| int`, `Union[str, int]`| Parameters accepting multiple types |
+| Constrained types | `Literal["A", "B"]`, `Enum` | Parameters with specific allowed values |
+| Paths | `Path` | File system paths (auto-converted from strings) |
+| UUIDs | `UUID` | Universally unique identifiers (auto-converted from strings) |
+| Pydantic models | `UserData` | Complex structured data with validation |
+
+FastMCP supports all types that Pydantic supports as fields, including all Pydantic custom types. A few FastMCP-specific behaviors to note:
+
+**Binary Data**: `bytes` parameters accept raw strings without automatic base64 decoding. For base64 data, use `str` and decode manually with `base64.b64decode()`.
+
+**Enums**: Clients send enum values (`"red"`), not names (`"RED"`). Your function receives the Enum member (`Color.RED`).
+
+**Paths and UUIDs**: String inputs are automatically converted to `Path` and `UUID` objects.
+
+**Pydantic Models**: Must be provided as JSON objects (dicts), not stringified JSON. Even with flexible validation, `{"user": {"name": "Alice"}}` works, but `{"user": '{"name": "Alice"}'}` does not.
+
+### Optional Arguments
+
+FastMCP follows Python's standard function parameter conventions. Parameters without default values are required, while those with default values are optional.
+
+```python
+@mcp.tool
+def search_products(
+ query: str, # Required - no default value
+ max_results: int = 10, # Optional - has default value
+ sort_by: str = "relevance", # Optional - has default value
+ category: str | None = None # Optional - can be None
+) -> list[dict]:
+ """Search the product catalog."""
+ # Implementation...
+```
+
+In this example, the LLM must provide a `query` parameter, while `max_results`, `sort_by`, and `category` will use their default values if not explicitly provided.
+
+### Validation Modes
+
+
+
+By default, FastMCP uses Pydantic's flexible validation that coerces compatible inputs to match your type annotations. This improves compatibility with LLM clients that may send string representations of values (like `"10"` for an integer parameter).
+
+If you need stricter validation that rejects any type mismatches, you can enable strict input validation. Strict mode uses the MCP SDK's built-in JSON Schema validation to validate inputs against the exact schema before passing them to your function:
+
+```python
+# Enable strict validation for this server
+mcp = FastMCP("StrictServer", strict_input_validation=True)
+
+@mcp.tool
+def add_numbers(a: int, b: int) -> int:
+ """Add two numbers."""
+ return a + b
+
+# With strict_input_validation=True, sending {"a": "10", "b": "20"} will fail
+# With strict_input_validation=False (default), it will be coerced to integers
+```
+
+**Validation Behavior Comparison:**
+
+| Input Type | strict_input_validation=False (default) | strict_input_validation=True |
+| :--------- | :-------------------------------------- | :--------------------------- |
+| String integers (`"10"` for `int`) | ✅ Coerced to integer | ❌ Validation error |
+| String floats (`"3.14"` for `float`) | ✅ Coerced to float | ❌ Validation error |
+| String booleans (`"true"` for `bool`) | ✅ Coerced to boolean | ❌ Validation error |
+| Lists with string elements (`["1", "2"]` for `list[int]`) | ✅ Elements coerced | ❌ Validation error |
+| Pydantic model fields with type mismatches | ✅ Fields coerced | ❌ Validation error |
+| Invalid values (`"abc"` for `int`) | ❌ Validation error | ❌ Validation error |
+
+
+**Note on Pydantic Models:** Even with `strict_input_validation=False`, Pydantic model parameters must be provided as JSON objects (dicts), not as stringified JSON. For example, `{"user": {"name": "Alice"}}` works, but `{"user": '{"name": "Alice"}'}` does not.
+
+
+The default flexible validation mode is recommended for most use cases as it handles common LLM client behaviors gracefully while still providing strong type safety through Pydantic's validation.
-For additional type annotations not listed here, see the [Parameter Types](#parameter-types) section below for more detailed information and examples.
### Parameter Metadata
You can provide additional metadata about parameters in several ways:
@@ -281,24 +341,6 @@ Field provides several validation and documentation features:
-### Optional Arguments
-
-FastMCP follows Python's standard function parameter conventions. Parameters without default values are required, while those with default values are optional.
-
-```python
-@mcp.tool
-def search_products(
- query: str, # Required - no default value
- max_results: int = 10, # Optional - has default value
- sort_by: str = "relevance", # Optional - has default value
- category: str | None = None # Optional - can be None
-) -> list[dict]:
- """Search the product catalog."""
- # Implementation...
-```
-
-In this example, the LLM must provide a `query` parameter, while `max_results`, `sort_by`, and `category` will use their default values if not explicitly provided.
-
### Excluding Arguments
@@ -322,34 +364,8 @@ With this configuration, `user_id` will not appear in the tool's parameter schem
For more complex tool transformations, see [Transforming Tools](/patterns/tool-transformation).
-### Disabling Tools
-
-
-You can control the visibility and availability of tools by enabling or disabling them. This is useful for feature flagging, maintenance, or dynamically changing the toolset available to a client. Disabled tools will not appear in the list of available tools returned by `list_tools`, and attempting to call a disabled tool will result in an "Unknown tool" error, just as if the tool did not exist.
-
-By default, all tools are enabled. You can disable a tool upon creation using the `enabled` parameter in the decorator:
-
-```python
-@mcp.tool(enabled=False)
-def maintenance_tool():
- """This tool is currently under maintenance."""
- return "This tool is disabled."
-```
-
-You can also toggle a tool's state programmatically after it has been created:
-
-```python
-@mcp.tool
-def dynamic_tool():
- return "I am a dynamic tool."
-
-# Disable and re-enable the tool
-dynamic_tool.disable()
-dynamic_tool.enable()
-```
-
-### Return Values
+## Return Values
FastMCP tools can return data in two complementary formats: **traditional content blocks** (like text and images) and **structured outputs** (machine-readable JSON). When you add return type annotations, FastMCP automatically generates **output schemas** to validate the structured data and enables clients to deserialize results back to Python objects.
@@ -362,7 +378,7 @@ Understanding how these three concepts work together:
The following sections explain each concept in detail.
-#### Content Blocks
+### Content Blocks
FastMCP automatically converts tool return values into appropriate MCP content blocks:
@@ -374,7 +390,43 @@ FastMCP automatically converts tool return values into appropriate MCP content b
- **A list of any of the above**: Converts each item appropriately
- **`None`**: Results in an empty response
-#### Structured Output
+#### Media Helper Classes
+
+For returning images, audio, and files, FastMCP provides helper classes that handle MIME type detection and base64 encoding automatically, returning them in MCP-native formats that meet the protocol's requirements:
+
+```python
+from fastmcp.utilities.types import Image, Audio, File
+
+@mcp.tool
+def get_chart() -> Image:
+ """Generate a chart image."""
+ # From file path - MIME type detected from extension
+ return Image(path="chart.png")
+
+ # Or from raw bytes with explicit format
+ # return Image(data=image_bytes, format="png")
+
+@mcp.tool
+def get_recording() -> Audio:
+ """Get an audio recording."""
+ return Audio(path="recording.wav")
+ # Or: Audio(data=audio_bytes, format="wav")
+
+@mcp.tool
+def get_document() -> File:
+ """Retrieve a PDF document."""
+ return File(path="report.pdf")
+ # Or: File(data=pdf_bytes, format="pdf", name="report")
+```
+
+Each helper class accepts either `path=` or `data=` (mutually exclusive):
+- **`path`**: File path (string or Path object) - MIME type detected from extension
+- **`data`**: Raw bytes - requires `format=` parameter for MIME type
+- **`format`**: Optional format override (e.g., "png", "wav", "pdf")
+- **`name`**: Optional name for `File` when using `data=`
+- **`annotations`**: Optional MCP annotations for the content
+
+### Structured Output
@@ -389,7 +441,7 @@ The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/speci
This automatic behavior enables clients to receive machine-readable data alongside human-readable content without requiring explicit output schemas for object-like returns.
-##### Object-like Results (Automatic Structured Content)
+#### Object-like Results (Automatic Structured Content)
```python Dict Return (No Schema Needed)
@@ -412,7 +464,7 @@ def get_user_data(user_id: str) -> dict:
```
-##### Non-object Results (Schema Required)
+#### Non-object Results (Schema Required)
```python Integer Return (No Schema)
@@ -444,7 +496,7 @@ def calculate_sum(a: int, b: int) -> int:
```
-##### Complex Type Example
+#### Complex Type Example
```python Tool Definition
@@ -487,7 +539,7 @@ def get_user_profile(user_id: str) -> Person:
```
-#### Output Schemas
+### Output Schemas
@@ -495,7 +547,7 @@ The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/speci
When you add return type annotations to your functions, FastMCP automatically generates JSON schemas that describe the expected output format. These schemas help MCP clients understand and validate the structured data they receive.
-##### Primitive Type Wrapping
+#### Primitive Type Wrapping
For primitive return types (like `int`, `str`, `bool`), FastMCP automatically wraps the result under a `"result"` key to create valid structured output:
@@ -524,7 +576,7 @@ def calculate_sum(a: int, b: int) -> int:
```
-##### Manual Schema Control
+#### Manual Schema Control
You can override the automatically generated schema by providing a custom `output_schema`:
@@ -550,7 +602,7 @@ Schema generation works for most common types including basic types, collections
- However, you can provide structured output without an output schema (using `ToolResult`)
-#### Full Control with ToolResult
+### Full Control with ToolResult
For complete control over both traditional content and structured output, return a `ToolResult` object:
@@ -575,7 +627,7 @@ When returning `ToolResult`:
If your return type annotation cannot be converted to a JSON schema (e.g., complex custom classes without Pydantic support), the output schema will be omitted but the tool will still function normally with traditional content.
-### Error Handling
+## Error Handling
@@ -613,7 +665,33 @@ def divide(a: float, b: float) -> float:
When `mask_error_details=True`, only error messages from `ToolError` will include details, other exceptions will be converted to a generic message.
-### Annotations
+## Disabling Tools
+
+
+
+You can control the visibility and availability of tools by enabling or disabling them. This is useful for feature flagging, maintenance, or dynamically changing the toolset available to a client. Disabled tools will not appear in the list of available tools returned by `list_tools`, and attempting to call a disabled tool will result in an "Unknown tool" error, just as if the tool did not exist.
+
+By default, all tools are enabled. You can disable a tool upon creation using the `enabled` parameter in the decorator:
+
+```python
+@mcp.tool(enabled=False)
+def maintenance_tool():
+ """This tool is currently under maintenance."""
+ return "This tool is disabled."
+```
+
+You can also toggle a tool's state programmatically after it has been created:
+
+```python
+@mcp.tool
+def dynamic_tool():
+ return "I am a dynamic tool."
+
+# Disable and re-enable the tool
+dynamic_tool.disable()
+dynamic_tool.enable()
+```
+## MCP Annotations
@@ -652,7 +730,7 @@ FastMCP supports these standard annotations:
Remember that annotations help make better user experiences but should be treated as advisory hints. They help client applications present appropriate UI elements and safety controls, but won't enforce security boundaries on their own. Always focus on making your annotations accurately represent what your tool actually does.
-### Notifications
+## Notifications
@@ -674,7 +752,7 @@ Notifications are only sent when these operations occur within an active MCP req
Clients can handle these notifications using a [message handler](/clients/messages) to automatically refresh their tool lists or update their interfaces.
-## MCP Context
+## Accessing the MCP Context
Tools can access MCP features like logging, reading resources, or reporting progress through the `Context` object. To use it, add a parameter to your tool function with the type hint `Context`.
@@ -715,333 +793,6 @@ The Context object provides access to:
For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
-## Parameter Types
-
-FastMCP supports a wide variety of parameter types to give you flexibility when designing your tools.
-
-FastMCP generally supports all types that Pydantic supports as fields, including all Pydantic custom types. This means you can use any type that can be validated and parsed by Pydantic in your tool parameters.
-
-FastMCP supports **type coercion** when possible. This means that if a client sends data that doesn't match the expected type, FastMCP will attempt to convert it to the appropriate type. For example, if a client sends a string for a parameter annotated as `int`, FastMCP will attempt to convert it to an integer. If the conversion is not possible, FastMCP will return a validation error.
-
-### Built-in Types
-
-The most common parameter types are Python's built-in scalar types:
-
-```python
-@mcp.tool
-def process_values(
- name: str, # Text data
- count: int, # Integer numbers
- amount: float, # Floating point numbers
- enabled: bool # Boolean values (True/False)
-):
- """Process various value types."""
- # Implementation...
-```
-
-These types provide clear expectations to the LLM about what values are acceptable and allow FastMCP to validate inputs properly. Even if a client provides a string like "42", it will be coerced to an integer for parameters annotated as `int`.
-
-### Date and Time Types
-
-FastMCP supports various date and time types from the `datetime` module:
-
-```python
-from datetime import datetime, date, timedelta
-
-@mcp.tool
-def process_date_time(
- event_date: date, # ISO format date string or date object
- event_time: datetime, # ISO format datetime string or datetime object
- duration: timedelta = timedelta(hours=1) # Integer seconds or timedelta
-) -> str:
- """Process date and time information."""
- # Types are automatically converted from strings
- assert isinstance(event_date, date)
- assert isinstance(event_time, datetime)
- assert isinstance(duration, timedelta)
-
- return f"Event on {event_date} at {event_time} for {duration}"
-```
-
-- `datetime` - Accepts ISO format strings (e.g., "2023-04-15T14:30:00")
-- `date` - Accepts ISO format date strings (e.g., "2023-04-15")
-- `timedelta` - Accepts integer seconds or timedelta objects
-
-### Collection Types
-
-FastMCP supports all standard Python collection types:
-
-```python
-@mcp.tool
-def analyze_data(
- values: list[float], # List of numbers
- properties: dict[str, str], # Dictionary with string keys and values
- unique_ids: set[int], # Set of unique integers
- coordinates: tuple[float, float], # Tuple with fixed structure
- mixed_data: dict[str, list[int]] # Nested collections
-):
- """Analyze collections of data."""
- # Implementation...
-```
-
-All collection types can be used as parameter annotations:
-- `list[T]` - Ordered sequence of items
-- `dict[K, V]` - Key-value mapping
-- `set[T]` - Unordered collection of unique items
-- `tuple[T1, T2, ...]` - Fixed-length sequence with potentially different types
-
-Collection types can be nested and combined to represent complex data structures. JSON strings that match the expected structure will be automatically parsed and converted to the appropriate Python collection type.
-
-### Union and Optional Types
-
-For parameters that can accept multiple types or may be omitted:
-
-```python
-@mcp.tool
-def flexible_search(
- query: str | int, # Can be either string or integer
- filters: dict[str, str] | None = None, # Optional dictionary
- sort_field: str | None = None # Optional string
-):
- """Search with flexible parameter types."""
- # Implementation...
-```
-
-Modern Python syntax (`str | int`) is preferred over older `Union[str, int]` forms. Similarly, `str | None` is preferred over `Optional[str]`.
-
-### Constrained Types
-
-When a parameter must be one of a predefined set of values, you can use either Literal types or Enums:
-
-#### Literals
-
-Literals constrain parameters to a specific set of values:
-
-```python
-from typing import Literal
-
-@mcp.tool
-def sort_data(
- data: list[float],
- order: Literal["ascending", "descending"] = "ascending",
- algorithm: Literal["quicksort", "mergesort", "heapsort"] = "quicksort"
-):
- """Sort data using specific options."""
- # Implementation...
-```
-
-Literal types:
-- Specify exact allowable values directly in the type annotation
-- Help LLMs understand exactly which values are acceptable
-- Provide input validation (errors for invalid values)
-- Create clear schemas for clients
-
-#### Enums
-
-For more structured sets of constrained values, use Python's Enum class:
-
-```python
-from enum import Enum
-
-class Color(Enum):
- RED = "red"
- GREEN = "green"
- BLUE = "blue"
-
-@mcp.tool
-def process_image(
- image_path: str,
- color_filter: Color = Color.RED
-):
- """Process an image with a color filter."""
- # Implementation...
- # color_filter will be a Color enum member
-```
-
-When using Enum types:
-- Clients should provide the enum's value (e.g., "red"), not the enum member name (e.g., "RED")
-- FastMCP automatically coerces the string value into the appropriate Enum object
-- Your function receives the actual Enum member (e.g., `Color.RED`)
-- Validation errors are raised for values not in the enum
-
-### Binary Data
-
-There are two approaches to handling binary data in tool parameters:
-
-#### Bytes
-
-```python
-@mcp.tool
-def process_binary(data: bytes):
- """Process binary data directly.
-
- The client can send a binary string, which will be
- converted directly to bytes.
- """
- # Implementation using binary data
- data_length = len(data)
- # ...
-```
-
-When you annotate a parameter as `bytes`, FastMCP will:
-- Convert raw strings directly to bytes
-- Validate that the input can be properly represented as bytes
-
-FastMCP does not automatically decode base64-encoded strings for bytes parameters. If you need to accept base64-encoded data, you should handle the decoding manually as shown below.
-
-#### Base64-encoded strings
-
-```python
-from typing import Annotated
-from pydantic import Field
-
-@mcp.tool
-def process_image_data(
- image_data: Annotated[str, Field(description="Base64-encoded image data")]
-):
- """Process an image from base64-encoded string.
-
- The client is expected to provide base64-encoded data as a string.
- You'll need to decode it manually.
- """
- # Manual base64 decoding
- import base64
- binary_data = base64.b64decode(image_data)
- # Process binary_data...
-```
-
-This approach is recommended when you expect to receive base64-encoded binary data from clients.
-
-### Paths
-
-The `Path` type from the `pathlib` module can be used for file system paths:
-
-```python
-from pathlib import Path
-
-@mcp.tool
-def process_file(path: Path) -> str:
- """Process a file at the given path."""
- assert isinstance(path, Path) # Path is properly converted
- return f"Processing file at {path}"
-```
-
-When a client sends a string path, FastMCP automatically converts it to a `Path` object.
-
-### UUIDs
-
-The `UUID` type from the `uuid` module can be used for unique identifiers:
-
-```python
-import uuid
-
-@mcp.tool
-def process_item(
- item_id: uuid.UUID # String UUID or UUID object
-) -> str:
- """Process an item with the given UUID."""
- assert isinstance(item_id, uuid.UUID) # Properly converted to UUID
- return f"Processing item {item_id}"
-```
-
-When a client sends a string UUID (e.g., "123e4567-e89b-12d3-a456-426614174000"), FastMCP automatically converts it to a `UUID` object.
-
-### Pydantic Models
-
-For complex, structured data with nested fields and validation, use Pydantic models:
-
-```python
-from pydantic import BaseModel, Field
-from typing import Optional
-
-class User(BaseModel):
- username: str
- email: str = Field(description="User's email address")
- age: int | None = None
- is_active: bool = True
-
-@mcp.tool
-def create_user(user: User):
- """Create a new user in the system."""
- # The input is automatically validated against the User model
- # Even if provided as a JSON string or dict
- # Implementation...
-```
-
-Using Pydantic models provides:
-- Clear, self-documenting structure for complex inputs
-- Built-in data validation
-- Automatic generation of detailed JSON schemas for the LLM
-- Automatic conversion from dict/JSON input
-
-Clients can provide data for Pydantic model parameters as either:
-- A JSON object (string)
-- A dictionary with the appropriate structure
-- Nested parameters in the appropriate format
-
-### Pydantic Fields
-
-FastMCP supports robust parameter validation through Pydantic's `Field` class. This is especially useful to ensure that input values meet specific requirements beyond just their type.
-
-Note that fields can be used *outside* Pydantic models to provide metadata and validation constraints. The preferred approach is using `Annotated` with `Field`:
-
-```python
-from typing import Annotated
-from pydantic import Field
-
-@mcp.tool
-def analyze_metrics(
- # Numbers with range constraints
- count: Annotated[int, Field(ge=0, le=100)], # 0 <= count <= 100
- ratio: Annotated[float, Field(gt=0, lt=1.0)], # 0 < ratio < 1.0
-
- # String with pattern and length constraints
- user_id: Annotated[str, Field(
- pattern=r"^[A-Z]{2}\d{4}$", # Must match regex pattern
- description="User ID in format XX0000"
- )],
-
- # String with length constraints
- comment: Annotated[str, Field(min_length=3, max_length=500)] = "",
-
- # Numeric constraints
- factor: Annotated[int, Field(multiple_of=5)] = 10, # Must be multiple of 5
-):
- """Analyze metrics with validated parameters."""
- # Implementation...
-```
-
-You can also use `Field` as a default value, though the `Annotated` approach is preferred:
-
-```python
-@mcp.tool
-def validate_data(
- # Value constraints
- age: int = Field(ge=0, lt=120), # 0 <= age < 120
-
- # String constraints
- email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$"), # Email pattern
-
- # Collection constraints
- tags: list[str] = Field(min_length=1, max_length=10) # 1-10 tags
-):
- """Process data with field validations."""
- # Implementation...
-```
-
-Common validation options include:
-
-| Validation | Type | Description |
-| :--------- | :--- | :---------- |
-| `ge`, `gt` | Number | Greater than (or equal) constraint |
-| `le`, `lt` | Number | Less than (or equal) constraint |
-| `multiple_of` | Number | Value must be a multiple of this number |
-| `min_length`, `max_length` | String, List, etc. | Length constraints |
-| `pattern` | String | Regular expression pattern constraint |
-| `description` | Any | Human-readable description (appears in schema) |
-
-When a client sends invalid data, FastMCP will return a validation error explaining why the parameter failed validation.
-
## Server Behavior
### Duplicate Tools
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index 25db16cd1..8d7dbc05c 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -150,6 +150,7 @@ class FastMCP(Generic[LifespanResultT]):
on_duplicate_tools: DuplicateBehavior | None = None,
on_duplicate_resources: DuplicateBehavior | None = None,
on_duplicate_prompts: DuplicateBehavior | None = None,
+ strict_input_validation: bool | None = None,
# ---
# ---
# --- The following arguments are DEPRECATED ---
@@ -219,6 +220,11 @@ class FastMCP(Generic[LifespanResultT]):
self.include_tags = include_tags
self.exclude_tags = exclude_tags
+ self.strict_input_validation = (
+ strict_input_validation
+ if strict_input_validation is not None
+ else fastmcp.settings.strict_input_validation
+ )
self.middleware = middleware or []
@@ -391,7 +397,9 @@ class FastMCP(Generic[LifespanResultT]):
self._mcp_server.list_resources()(self._list_resources_mcp)
self._mcp_server.list_resource_templates()(self._list_resource_templates_mcp)
self._mcp_server.list_prompts()(self._list_prompts_mcp)
- self._mcp_server.call_tool()(self._call_tool_mcp)
+ self._mcp_server.call_tool(validate_input=self.strict_input_validation)(
+ self._call_tool_mcp
+ )
self._mcp_server.read_resource()(self._read_resource_mcp)
self._mcp_server.get_prompt()(self._get_prompt_mcp)
diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py
index ac8ce6df1..ae4b5e617 100644
--- a/src/fastmcp/settings.py
+++ b/src/fastmcp/settings.py
@@ -194,7 +194,6 @@ class Settings(BaseSettings):
client_raise_first_exceptiongroup_error: Annotated[
bool,
Field(
- default=True,
description=inspect.cleandoc(
"""
Many MCP components operate in anyio taskgroups, and raise
@@ -210,7 +209,6 @@ class Settings(BaseSettings):
resource_prefix_format: Annotated[
Literal["protocol", "path"],
Field(
- default="path",
description=inspect.cleandoc(
"""
When perfixing a resource URI, either use path formatting (resource://prefix/path)
@@ -240,7 +238,6 @@ class Settings(BaseSettings):
mask_error_details: Annotated[
bool,
Field(
- default=False,
description=inspect.cleandoc(
"""
If True, error details from user-supplied functions (tool, resource, prompt)
@@ -253,6 +250,22 @@ class Settings(BaseSettings):
),
] = False
+ strict_input_validation: Annotated[
+ bool,
+ Field(
+ description=inspect.cleandoc(
+ """
+ If True, tool inputs are strictly validated against the input
+ JSON schema. For example, providing the string \"10\" to an
+ integer field will raise an error. If False, compatible inputs
+ will be coerced to match the schema, which can increase
+ compatibility. For example, providing the string \"10\" to an
+ integer field will be coerced to 10. Defaults to False.
+ """
+ ),
+ ),
+ ] = False
+
server_dependencies: list[str] = Field(
default_factory=list,
description="List of dependencies to install in the server environment",
@@ -298,7 +311,6 @@ class Settings(BaseSettings):
include_tags: Annotated[
set[str] | None,
Field(
- default=None,
description=inspect.cleandoc(
"""
If provided, only components that match these tags will be
@@ -311,7 +323,6 @@ class Settings(BaseSettings):
exclude_tags: Annotated[
set[str] | None,
Field(
- default=None,
description=inspect.cleandoc(
"""
If provided, components that match these tags will be excluded
@@ -325,7 +336,6 @@ class Settings(BaseSettings):
include_fastmcp_meta: Annotated[
bool,
Field(
- default=True,
description=inspect.cleandoc(
"""
Whether to include FastMCP meta in the server's MCP responses.
@@ -340,7 +350,6 @@ class Settings(BaseSettings):
mounted_components_raise_on_load_error: Annotated[
bool,
Field(
- default=False,
description=inspect.cleandoc(
"""
If True, errors encountered when loading mounted components (tools, resources, prompts)
@@ -354,7 +363,6 @@ class Settings(BaseSettings):
show_cli_banner: Annotated[
bool,
Field(
- default=True,
description=inspect.cleandoc(
"""
If True, the server banner will be displayed when running the server via CLI.
diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py
index cd74dc18e..f1ea00cbc 100644
--- a/src/fastmcp/tools/tool_manager.py
+++ b/src/fastmcp/tools/tool_manager.py
@@ -5,6 +5,7 @@ from collections.abc import Callable
from typing import Any
from mcp.types import ToolAnnotations
+from pydantic import ValidationError
from fastmcp import settings
from fastmcp.exceptions import NotFoundError, ToolError
@@ -153,6 +154,9 @@ class ToolManager:
tool = await self.get_tool(key)
try:
return await tool.run(arguments)
+ except ValidationError as e:
+ logger.exception(f"Error validating tool {key!r}: {e}")
+ raise e
except ToolError as e:
logger.exception(f"Error calling tool {key!r}")
raise e
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
index 009103233..a32e92073 100644
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -678,7 +678,8 @@ class TestErrorHandling:
async with Client(transport=FastMCPTransport(mcp)) as client:
result = await client.call_tool_mcp("validated_tool", {"x": "abc"})
assert result.isError
- assert "'abc' is not of type 'integer'" in result.content[0].text # type: ignore[attr-defined]
+ # Pydantic validation error message should NOT be masked
+ assert "Input should be a valid integer" in result.content[0].text # type: ignore[attr-defined]
async def test_specific_tool_errors_are_sent_to_client(self):
mcp = FastMCP("TestServer")
diff --git a/tests/server/test_input_validation.py b/tests/server/test_input_validation.py
new file mode 100644
index 000000000..e50f2ee7e
--- /dev/null
+++ b/tests/server/test_input_validation.py
@@ -0,0 +1,353 @@
+"""
+Tests for input validation behavior with strict_input_validation setting.
+
+This module tests the difference between strict JSON schema validation (when
+strict_input_validation=True) and Pydantic-based coercion (when
+strict_input_validation=False, the default).
+"""
+
+import json
+
+import pytest
+from pydantic import BaseModel
+
+from fastmcp import Client, FastMCP
+
+
+class UserProfile(BaseModel):
+ """A test model for validating Pydantic model arguments."""
+
+ name: str
+ age: int
+ email: str
+
+
+class TestStringToIntegerCoercion:
+ """Test string-to-integer coercion behavior."""
+
+ async def test_string_integer_with_strict_validation(self):
+ """With strict validation, string integers should raise an error."""
+ mcp = FastMCP("TestServer", strict_input_validation=True)
+
+ @mcp.tool
+ def add_numbers(a: int, b: int) -> int:
+ """Add two numbers together."""
+ return a + b
+
+ async with Client(mcp) as client:
+ # String integers should fail with strict validation
+ with pytest.raises(Exception) as exc_info:
+ await client.call_tool("add_numbers", {"a": "10", "b": "20"})
+
+ # Verify it's a validation error
+ error_msg = str(exc_info.value).lower()
+ assert (
+ "validation" in error_msg
+ or "invalid" in error_msg
+ or "type" in error_msg
+ )
+
+ async def test_string_integer_without_strict_validation(self):
+ """Without strict validation, string integers should be coerced."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def add_numbers(a: int, b: int) -> int:
+ """Add two numbers together."""
+ return a + b
+
+ async with Client(mcp) as client:
+ # String integers should be coerced to integers
+ result = await client.call_tool("add_numbers", {"a": "10", "b": "20"})
+ assert result.content[0].text == "30" # type: ignore[attr-defined]
+
+ async def test_default_is_not_strict(self):
+ """By default, strict_input_validation should be False."""
+ mcp = FastMCP("TestServer")
+
+ @mcp.tool
+ def multiply(x: int, y: int) -> int:
+ """Multiply two numbers."""
+ return x * y
+
+ async with Client(mcp) as client:
+ # Should work with string integers by default
+ result = await client.call_tool("multiply", {"x": "5", "y": "3"})
+ assert result.content[0].text == "15" # type: ignore[attr-defined]
+
+ async def test_string_float_coercion(self):
+ """Test that string floats are also coerced."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def calculate_area(length: float, width: float) -> float:
+ """Calculate rectangle area."""
+ return length * width
+
+ async with Client(mcp) as client:
+ result = await client.call_tool(
+ "calculate_area", {"length": "10.5", "width": "20.0"}
+ )
+ assert result.content[0].text == "210.0" # type: ignore[attr-defined]
+
+ async def test_invalid_coercion_still_fails(self):
+ """Even without strict validation, truly invalid inputs should fail."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def square(n: int) -> int:
+ """Square a number."""
+ return n * n
+
+ async with Client(mcp) as client:
+ # Non-numeric strings should still fail
+ with pytest.raises(Exception):
+ await client.call_tool("square", {"n": "not-a-number"})
+
+
+class TestPydanticModelArguments:
+ """Test validation of Pydantic model arguments."""
+
+ async def test_pydantic_model_with_dict_no_strict(self):
+ """Pydantic models should accept dict arguments without strict validation."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def create_user(profile: UserProfile) -> str:
+ """Create a user from a profile."""
+ return f"Created user {profile.name}, age {profile.age}"
+
+ async with Client(mcp) as client:
+ result = await client.call_tool(
+ "create_user",
+ {"profile": {"name": "Alice", "age": 30, "email": "alice@example.com"}},
+ )
+ assert "Alice" in result.content[0].text # type: ignore[attr-defined]
+ assert "30" in result.content[0].text # type: ignore[attr-defined]
+
+ async def test_pydantic_model_with_stringified_json_no_strict(self):
+ """Test if stringified JSON is accepted for Pydantic models without strict validation."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def create_user(profile: UserProfile) -> str:
+ """Create a user from a profile."""
+ return f"Created user {profile.name}, age {profile.age}"
+
+ async with Client(mcp) as client:
+ # Some LLM clients send stringified JSON instead of actual JSON
+ stringified = json.dumps(
+ {"name": "Bob", "age": 25, "email": "bob@example.com"}
+ )
+
+ # This test verifies whether we handle stringified JSON
+ try:
+ result = await client.call_tool("create_user", {"profile": stringified})
+ # If this succeeds, we're handling stringified JSON
+ assert "Bob" in result.content[0].text # type: ignore[attr-defined]
+ stringified_json_works = True
+ except Exception as e:
+ # If this fails, we're not handling stringified JSON
+ stringified_json_works = False
+ error_msg = str(e)
+
+ # Document the behavior - we want to know if this works or not
+ if stringified_json_works:
+ # This is the desired behavior
+ pass
+ else:
+ # This means stringified JSON doesn't work - document it
+ assert (
+ "validation" in error_msg.lower() or "invalid" in error_msg.lower()
+ )
+
+ async def test_pydantic_model_with_coercion(self):
+ """Pydantic models should benefit from coercion without strict validation."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def create_user(profile: UserProfile) -> str:
+ """Create a user from a profile."""
+ return f"Created user {profile.name}, age {profile.age}"
+
+ async with Client(mcp) as client:
+ # Age as string should be coerced
+ result = await client.call_tool(
+ "create_user",
+ {
+ "profile": {
+ "name": "Charlie",
+ "age": "35", # String instead of int
+ "email": "charlie@example.com",
+ }
+ },
+ )
+ assert "Charlie" in result.content[0].text # type: ignore[attr-defined]
+ assert "35" in result.content[0].text # type: ignore[attr-defined]
+
+ async def test_pydantic_model_strict_validation(self):
+ """With strict validation, Pydantic models should enforce exact types."""
+ mcp = FastMCP("TestServer", strict_input_validation=True)
+
+ @mcp.tool
+ def create_user(profile: UserProfile) -> str:
+ """Create a user from a profile."""
+ return f"Created user {profile.name}, age {profile.age}"
+
+ async with Client(mcp) as client:
+ # Age as string should fail with strict validation
+ with pytest.raises(Exception):
+ await client.call_tool(
+ "create_user",
+ {
+ "profile": {
+ "name": "Dave",
+ "age": "40", # String instead of int
+ "email": "dave@example.com",
+ }
+ },
+ )
+
+
+class TestValidationErrorMessages:
+ """Test the quality of validation error messages."""
+
+ async def test_error_message_quality_strict(self):
+ """Capture error message with strict validation."""
+ mcp = FastMCP("TestServer", strict_input_validation=True)
+
+ @mcp.tool
+ def process_data(count: int, name: str) -> str:
+ """Process some data."""
+ return f"Processed {count} items for {name}"
+
+ async with Client(mcp) as client:
+ with pytest.raises(Exception) as exc_info:
+ await client.call_tool(
+ "process_data", {"count": "not-a-number", "name": "test"}
+ )
+
+ error_msg = str(exc_info.value)
+ # Strict validation error message
+ # Should mention validation or type error
+ assert (
+ "validation" in error_msg.lower()
+ or "invalid" in error_msg.lower()
+ or "type" in error_msg.lower()
+ )
+
+ async def test_error_message_quality_pydantic(self):
+ """Capture error message with Pydantic validation."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def process_data(count: int, name: str) -> str:
+ """Process some data."""
+ return f"Processed {count} items for {name}"
+
+ async with Client(mcp) as client:
+ with pytest.raises(Exception) as exc_info:
+ await client.call_tool(
+ "process_data", {"count": "not-a-number", "name": "test"}
+ )
+
+ error_msg = str(exc_info.value)
+ # Pydantic validation error message
+ # Should be more detailed and mention validation
+ assert "validation" in error_msg.lower() or "invalid" in error_msg.lower()
+
+ async def test_missing_required_field_error(self):
+ """Test error message for missing required fields."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def greet(name: str, age: int) -> str:
+ """Greet a person."""
+ return f"Hello {name}, you are {age} years old"
+
+ async with Client(mcp) as client:
+ with pytest.raises(Exception) as exc_info:
+ # Missing 'age' parameter
+ await client.call_tool("greet", {"name": "Alice"})
+
+ error_msg = str(exc_info.value)
+ # Should mention the missing field
+ assert "age" in error_msg.lower() or "required" in error_msg.lower()
+
+
+class TestEdgeCases:
+ """Test edge cases and boundary conditions."""
+
+ async def test_optional_parameters_with_coercion(self):
+ """Optional parameters should work with coercion."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def format_message(text: str, repeat: int = 1) -> str:
+ """Format a message with optional repetition."""
+ return text * repeat
+
+ async with Client(mcp) as client:
+ # String for optional int parameter
+ result = await client.call_tool(
+ "format_message", {"text": "hi", "repeat": "3"}
+ )
+ assert result.content[0].text == "hihihi" # type: ignore[attr-defined]
+
+ async def test_none_values(self):
+ """Test handling of None values."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def process_optional(value: int | None) -> str:
+ """Process an optional value."""
+ return f"Value: {value}"
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("process_optional", {"value": None})
+ assert "None" in result.content[0].text # type: ignore[attr-defined]
+
+ async def test_empty_string_to_int(self):
+ """Empty strings should fail conversion to int."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def square(n: int) -> int:
+ """Square a number."""
+ return n * n
+
+ async with Client(mcp) as client:
+ with pytest.raises(Exception):
+ await client.call_tool("square", {"n": ""})
+
+ async def test_boolean_coercion(self):
+ """Test boolean value coercion."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def toggle(enabled: bool) -> str:
+ """Toggle a feature."""
+ return f"Feature is {'enabled' if enabled else 'disabled'}"
+
+ async with Client(mcp) as client:
+ # String "true" should be coerced to boolean
+ result = await client.call_tool("toggle", {"enabled": "true"})
+ assert "enabled" in result.content[0].text.lower() # type: ignore[attr-defined]
+
+ # String "false" should be coerced to boolean
+ result = await client.call_tool("toggle", {"enabled": "false"})
+ assert "disabled" in result.content[0].text.lower() # type: ignore[attr-defined]
+
+ async def test_list_of_integers_with_string_elements(self):
+ """Test lists containing string representations of integers."""
+ mcp = FastMCP("TestServer", strict_input_validation=False)
+
+ @mcp.tool
+ def sum_numbers(numbers: list[int]) -> int:
+ """Sum a list of numbers."""
+ return sum(numbers)
+
+ async with Client(mcp) as client:
+ # List with string integers
+ result = await client.call_tool("sum_numbers", {"numbers": ["1", "2", "3"]})
+ assert result.content[0].text == "6" # type: ignore[attr-defined]
diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py
index 030cd4223..76f1dffb2 100644
--- a/tests/server/test_server_interactions.py
+++ b/tests/server/test_server_interactions.py
@@ -604,12 +604,12 @@ class TestToolParameters:
async with Client(mcp) as client:
with pytest.raises(
ToolError,
- match="Input validation error: 'not an int' is not of type 'integer'",
+ match="Input should be a valid integer",
):
await client.call_tool("my_tool", {"x": "not an int"})
async def test_tool_int_coercion(self):
- """Test that invalid int input raises validation error."""
+ """Test that string ints are coerced by default."""
mcp = FastMCP()
@mcp.tool
@@ -617,15 +617,12 @@ class TestToolParameters:
return x + 1
async with Client(mcp) as client:
- # String input should raise validation error (no coercion)
- with pytest.raises(
- ToolError,
- match="Input validation error: '42' is not of type 'integer'",
- ):
- await client.call_tool("add_one", {"x": "42"})
+ # String input should be coerced with default settings
+ result = await client.call_tool("add_one", {"x": "42"})
+ assert result.data == 43
async def test_tool_bool_coercion(self):
- """Test that invalid bool input raises validation error."""
+ """Test that string bools are coerced by default."""
mcp = FastMCP()
@mcp.tool
@@ -633,18 +630,12 @@ class TestToolParameters:
return not flag
async with Client(mcp) as client:
- # String input should raise validation error (no coercion)
- with pytest.raises(
- ToolError,
- match="Input validation error: 'true' is not of type 'boolean'",
- ):
- await client.call_tool("toggle", {"flag": "true"})
+ # String input should be coerced with default settings
+ result = await client.call_tool("toggle", {"flag": "true"})
+ assert result.data is False
- with pytest.raises(
- ToolError,
- match="Input validation error: 'false' is not of type 'boolean'",
- ):
- await client.call_tool("toggle", {"flag": "false"})
+ result = await client.call_tool("toggle", {"flag": "false"})
+ assert result.data is True
async def test_annotated_field_validation(self):
mcp = FastMCP()
@@ -656,7 +647,7 @@ class TestToolParameters:
async with Client(mcp) as client:
with pytest.raises(
ToolError,
- match="Input validation error: 0 is less than the minimum of 1",
+ match="Input should be greater than or equal to 1",
):
await client.call_tool("analyze", {"x": 0})
@@ -670,7 +661,7 @@ class TestToolParameters:
async with Client(mcp) as client:
with pytest.raises(
ToolError,
- match="Input validation error: 0 is less than the minimum of 1",
+ match="Input should be greater than or equal to 1",
):
await client.call_tool("analyze", {"x": 0})
@@ -682,9 +673,7 @@ class TestToolParameters:
pass
async with Client(mcp) as client:
- with pytest.raises(
- ToolError, match="Input validation error: 'x' is a required property"
- ):
+ with pytest.raises(ToolError, match="Missing required argument"):
await client.call_tool("analyze", {})
async def test_literal_type_validation_error(self):
@@ -697,7 +686,7 @@ class TestToolParameters:
async with Client(mcp) as client:
with pytest.raises(
ToolError,
- match=r"Input validation error: 'c' is not one of \['a', 'b'\]",
+ match="Input should be 'a' or 'b'",
):
await client.call_tool("analyze", {"x": "c"})
@@ -727,7 +716,7 @@ class TestToolParameters:
async with Client(mcp) as client:
with pytest.raises(
ToolError,
- match=r"Input validation error: 'some-color' is not one of \['red', 'green', 'blue'\]",
+ match="Input should be 'red', 'green' or 'blue'",
):
await client.call_tool("analyze", {"x": "some-color"})
@@ -763,7 +752,7 @@ class TestToolParameters:
with pytest.raises(
ToolError,
- match="Input validation error: 'not a number' is not valid under any of the given schemas",
+ match="Input should be a valid",
):
await client.call_tool("analyze", {"x": "not a number"})
@@ -790,9 +779,7 @@ class TestToolParameters:
return str(path)
async with Client(mcp) as client:
- with pytest.raises(
- ToolError, match="Input validation error: 1 is not of type 'string'"
- ):
+ with pytest.raises(ToolError, match="Input is not a valid path"):
await client.call_tool("send_path", {"path": 1})
async def test_uuid_type(self):
@@ -817,7 +804,7 @@ class TestToolParameters:
return str(x)
async with Client(mcp) as client:
- with pytest.raises(ToolError, match="Error calling tool 'send_uuid'"):
+ with pytest.raises(ToolError, match="Input should be a valid UUID"):
await client.call_tool("send_uuid", {"x": "not a uuid"})
async def test_datetime_type(self):
@@ -854,7 +841,7 @@ class TestToolParameters:
return x.isoformat()
async with Client(mcp) as client:
- with pytest.raises(ToolError, match="Error calling tool 'send_datetime'"):
+ with pytest.raises(ToolError, match="Input should be a valid datetime"):
await client.call_tool("send_datetime", {"x": "not a datetime"})
async def test_date_type(self):
@@ -893,7 +880,7 @@ class TestToolParameters:
assert result.data == "1 day, 0:00:00"
async def test_timedelta_type_parse_int(self):
- """Test that invalid timedelta input raises validation error."""
+ """Test that int input is coerced to timedelta (seconds)."""
mcp = FastMCP()
@mcp.tool
@@ -901,12 +888,11 @@ class TestToolParameters:
return str(x)
async with Client(mcp) as client:
- # Int input should raise validation error (no conversion)
- with pytest.raises(
- ToolError,
- match="Input validation error: 1000 is not of type 'string'",
- ):
- await client.call_tool("send_timedelta", {"x": 1000})
+ # Int input should be coerced to timedelta (seconds)
+ result = await client.call_tool("send_timedelta", {"x": 1000})
+ assert (
+ "0:16:40" in result.data or "16:40" in result.data
+ ) # 1000 seconds = 16 minutes 40 seconds
async def test_annotated_string_description(self):
mcp = FastMCP()
diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py
index e39e0a7b8..1ece0dfd2 100644
--- a/tests/tools/test_tool_manager.py
+++ b/tests/tools/test_tool_manager.py
@@ -7,7 +7,7 @@ import pydantic_core
import pytest
from inline_snapshot import snapshot
from mcp.types import ImageContent, TextContent
-from pydantic import BaseModel
+from pydantic import BaseModel, ValidationError
from fastmcp import Context, FastMCP
from fastmcp.exceptions import NotFoundError, ToolError
@@ -472,7 +472,7 @@ class TestCallTools:
manager = ToolManager()
tool = Tool.from_function(add)
manager.add_tool(tool)
- with pytest.raises(ToolError):
+ with pytest.raises(ValidationError):
await manager.call_tool("add", {"a": 1})
async def test_call_unknown_tool(self):