Merge branch 'main' into bump-kv

This commit is contained in:
William Easton 2025-10-14 13:55:42 -05:00 committed by GitHub
commit 5d13deffc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1211 additions and 574 deletions

View file

@ -1,5 +1,5 @@
---
title: Server Logging
title: Client Logging
sidebarTitle: Logging
description: Send log messages back to MCP clients through the context.
icon: receipt
@ -71,12 +71,25 @@ async def process_transaction(transaction_id: str, amount: float, ctx: Context):
# ... processing logic ...
```
## Server Logs
Client Logging in the form of `ctx.log()` and its convenience methods (`debug`, `info`, `warning`, `error`) are meant for sending messages to the MCP clients. Messages sent to clients are also logged to the server's log at `DEBUG` level. Enable debug logging on the server or enable debug logging on the `fastmcp.server.context.to_client` logger to see these messages in the server's log.
```python
import logging
from fastmcp.utilities.logging import get_logger
to_client_logger = get_logger(name="fastmcp.server.context.to_client")
to_client_logger.setLevel(level=logging.DEBUG)
```
## Logging Methods
<Card icon="code" title="Context Logging Methods">
<ResponseField name="ctx.debug" type="async method">
Send debug-level messages for detailed execution information
<Expandable title="parameters">
<ResponseField name="message" type="str">
The debug message to send to the client
@ -89,7 +102,7 @@ async def process_transaction(transaction_id: str, amount: float, ctx: Context):
<ResponseField name="ctx.info" type="async method">
Send informational messages about normal execution
<Expandable title="parameters">
<ResponseField name="message" type="str">
The information message to send to the client
@ -102,7 +115,7 @@ async def process_transaction(transaction_id: str, amount: float, ctx: Context):
<ResponseField name="ctx.warning" type="async method">
Send warning messages for potential issues that didn't prevent execution
<Expandable title="parameters">
<ResponseField name="message" type="str">
The warning message to send to the client
@ -115,7 +128,7 @@ async def process_transaction(transaction_id: str, amount: float, ctx: Context):
<ResponseField name="ctx.error" type="async method">
Send error messages for problems that occurred during execution
<Expandable title="parameters">
<ResponseField name="message" type="str">
The error message to send to the client
@ -128,16 +141,16 @@ async def process_transaction(transaction_id: str, amount: float, ctx: Context):
<ResponseField name="ctx.log" type="async method">
Generic logging method with custom level and logger name
<Expandable title="parameters">
<ResponseField name="level" type="Literal['debug', 'info', 'warning', 'error']">
The log level for the message
</ResponseField>
<ResponseField name="message" type="str">
The message to send to the client
</ResponseField>
<ResponseField name="logger_name" type="str | None" default="None">
Optional custom logger name for categorizing messages
</ResponseField>

View file

@ -73,9 +73,15 @@ The `FastMCP` constructor accepts several arguments:
How to handle duplicate prompt registrations
</ParamField>
<ParamField body="strict_input_validation" type="bool" default="False">
<VersionBadge version="2.13.0" />
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
</ParamField>
<ParamField body="include_fastmcp_meta" type="bool" default="True">
<VersionBadge version="2.11.0" />
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
</ParamField>
</Card>
@ -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
```

View file

@ -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.
</Tip>
#### 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
</Card>
### 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:
```
</CodeGroup>
## 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
<VersionBadge version="2.13.0" />
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>
**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.
</Note>
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
<VersionBadge version="2.8.0" />
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
<VersionBadge version="2.10.0" />
@ -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.
</Note>
##### Object-like Results (Automatic Structured Content)
#### Object-like Results (Automatic Structured Content)
<CodeGroup>
```python Dict Return (No Schema Needed)
@ -412,7 +464,7 @@ def get_user_data(user_id: str) -> dict:
```
</CodeGroup>
##### Non-object Results (Schema Required)
#### Non-object Results (Schema Required)
<CodeGroup>
```python Integer Return (No Schema)
@ -444,7 +496,7 @@ def calculate_sum(a: int, b: int) -> int:
```
</CodeGroup>
##### Complex Type Example
#### Complex Type Example
<CodeGroup>
```python Tool Definition
@ -487,7 +539,7 @@ def get_user_profile(user_id: str) -> Person:
```
</CodeGroup>
#### Output Schemas
### Output Schemas
<VersionBadge version="2.10.0" />
@ -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:
```
</CodeGroup>
##### 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`)
</Warning>
#### 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.
</Note>
### Error Handling
## Error Handling
<VersionBadge version="2.4.1" />
@ -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
<VersionBadge version="2.8.0" />
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
<VersionBadge version="2.2.7" />
@ -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
<VersionBadge version="2.9.1" />
@ -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

View file

@ -172,7 +172,7 @@ async def run_command(
# handle v1 servers
if isinstance(server, FastMCP1x):
run_v1_server(server, host=host, port=port, transport=transport)
await run_v1_server_async(server, host=host, port=port, transport=transport)
return
kwargs = {}
@ -197,24 +197,29 @@ async def run_command(
sys.exit(1)
def run_v1_server(
async def run_v1_server_async(
server: FastMCP1x,
host: str | None = None,
port: int | None = None,
transport: TransportType | None = None,
) -> None:
from functools import partial
"""Run a FastMCP 1.x server using async methods.
Args:
server: FastMCP 1.x server instance
host: Host to bind to
port: Port to bind to
transport: Transport protocol to use
"""
if host:
server.settings.host = host
if port:
server.settings.port = port
match transport:
case "stdio":
runner = partial(server.run)
await server.run_stdio_async()
case "http" | "streamable-http" | None:
runner = partial(server.run, transport="streamable-http")
await server.run_streamable_http_async()
case "sse":
runner = partial(server.run, transport="sse")
runner()
await server.run_sse_async()

View file

@ -1,4 +1,5 @@
from collections.abc import Awaitable, Callable
from logging import Logger
from typing import TypeAlias
from mcp.client.session import LoggingFnT
@ -6,7 +7,8 @@ from mcp.types import LoggingMessageNotificationParams
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
logger: Logger = get_logger(name=__name__)
from_server_logger: Logger = get_logger(name="fastmcp.client.from_server")
LogMessage: TypeAlias = LoggingMessageNotificationParams
LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
@ -19,25 +21,27 @@ async def default_log_handler(message: LogMessage) -> None:
# Map MCP log levels to Python logging levels
level_map = {
"debug": logger.debug,
"info": logger.info,
"notice": logger.info, # Python doesn't have 'notice', map to info
"warning": logger.warning,
"error": logger.error,
"critical": logger.critical,
"alert": logger.critical, # Map alert to critical
"emergency": logger.critical, # Map emergency to critical
"debug": from_server_logger.debug,
"info": from_server_logger.info,
"notice": from_server_logger.info, # Python doesn't have 'notice', map to info
"warning": from_server_logger.warning,
"error": from_server_logger.error,
"critical": from_server_logger.critical,
"alert": from_server_logger.critical, # Map alert to critical
"emergency": from_server_logger.critical, # Map emergency to critical
}
# Get the appropriate logging function based on the message level
log_fn = level_map.get(message.level.lower(), logger.info)
# Include logger name if available
msg_prefix: str = f"Received {message.level.upper()} from server"
if message.logger:
msg = f"[{message.logger}] {msg}"
msg_prefix += f" ({message.logger})"
# Log with appropriate level and extra data
log_fn(f"Server log: {msg}", extra=extra)
log_fn(msg=f"{msg_prefix}: {msg}", extra=extra)
def create_log_callback(handler: LogHandler | None = None) -> LoggingFnT:

View file

@ -852,29 +852,42 @@ class FastMCPTransport(ClientTransport):
# Create a cancel scope for the server task
async with anyio.create_task_group() as tg:
tg.start_soon(
lambda: self.server._mcp_server.run(
server_read,
server_write,
self.server._mcp_server.create_initialization_options(),
raise_exceptions=self.raise_exceptions,
async with _enter_server_lifespan(server=self.server):
tg.start_soon(
lambda: self.server._mcp_server.run(
server_read,
server_write,
self.server._mcp_server.create_initialization_options(),
raise_exceptions=self.raise_exceptions,
)
)
)
try:
async with ClientSession(
read_stream=client_read,
write_stream=client_write,
**session_kwargs,
) as client_session:
yield client_session
finally:
tg.cancel_scope.cancel()
try:
async with ClientSession(
read_stream=client_read,
write_stream=client_write,
**session_kwargs,
) as client_session:
yield client_session
finally:
tg.cancel_scope.cancel()
def __repr__(self) -> str:
return f"<FastMCPTransport(server='{self.server.name}')>"
@contextlib.asynccontextmanager
async def _enter_server_lifespan(
server: FastMCP | FastMCP1Server,
) -> AsyncIterator[None]:
"""Enters the server's lifespan context for FastMCP servers and does nothing for FastMCP 1 servers."""
if isinstance(server, FastMCP):
async with server._lifespan_manager():
yield
else:
yield
class MCPConfigTransport(ClientTransport):
"""Transport for connecting to one or more MCP servers defined in an MCPConfig.

View file

@ -3,13 +3,16 @@ from __future__ import annotations
import asyncio
import copy
import inspect
import logging
import warnings
import weakref
from asyncio.locks import Lock
from collections.abc import Generator, Mapping, Sequence
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass
from enum import Enum
from logging import Logger
from typing import Any, Literal, cast, get_origin, overload
from mcp import LoggingLevel, ServerSession
@ -44,14 +47,21 @@ from fastmcp.server.elicitation import (
get_elicitation_schema,
)
from fastmcp.server.server import FastMCP
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.logging import _clamp_logger, get_logger
from fastmcp.utilities.types import get_cached_typeadapter
logger = get_logger(__name__)
logger: Logger = get_logger(name=__name__)
to_client_logger: Logger = logger.getChild(suffix="to_client")
# Convert all levels of server -> client messages to debug level
# This clamp can be undone at runtime by calling `_unclamp_logger` or calling
# `_clamp_logger` with a different max level.
_clamp_logger(logger=to_client_logger, max_level="DEBUG")
T = TypeVar("T", default=Any)
_current_context: ContextVar[Context | None] = ContextVar("context", default=None) # type: ignore[assignment]
_flush_lock = asyncio.Lock()
_flush_lock: Lock = asyncio.Lock()
@dataclass
@ -66,6 +76,18 @@ class LogData:
extra: Mapping[str, Any] | None = None
_mcp_level_to_python_level = {
"debug": logging.DEBUG,
"info": logging.INFO,
"notice": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
"alert": logging.CRITICAL,
"emergency": logging.CRITICAL,
}
@contextmanager
def set_context(context: Context) -> Generator[Context, None, None]:
token = _current_context.set(context)
@ -216,6 +238,8 @@ class Context:
) -> None:
"""Send a log message to the client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
Args:
message: Log message
level: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical",
@ -223,13 +247,13 @@ class Context:
logger_name: Optional logger name
extra: Optional mapping for additional arguments
"""
if level is None:
level = "info"
data = LogData(msg=message, extra=extra)
await self.session.send_log_message(
level=level,
await _log_to_server_and_client(
data=data,
logger=logger_name,
session=self.session,
level=level or "info",
logger_name=logger_name,
related_request_id=self.request_id,
)
@ -303,9 +327,14 @@ class Context:
logger_name: str | None = None,
extra: Mapping[str, Any] | None = None,
) -> None:
"""Send a debug log message."""
"""Send a `DEBUG`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`."""
await self.log(
level="debug", message=message, logger_name=logger_name, extra=extra
level="debug",
message=message,
logger_name=logger_name,
extra=extra,
)
async def info(
@ -314,9 +343,14 @@ class Context:
logger_name: str | None = None,
extra: Mapping[str, Any] | None = None,
) -> None:
"""Send an info log message."""
"""Send a `INFO`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`."""
await self.log(
level="info", message=message, logger_name=logger_name, extra=extra
level="info",
message=message,
logger_name=logger_name,
extra=extra,
)
async def warning(
@ -325,9 +359,14 @@ class Context:
logger_name: str | None = None,
extra: Mapping[str, Any] | None = None,
) -> None:
"""Send a warning log message."""
"""Send a `WARNING`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`."""
await self.log(
level="warning", message=message, logger_name=logger_name, extra=extra
level="warning",
message=message,
logger_name=logger_name,
extra=extra,
)
async def error(
@ -336,9 +375,14 @@ class Context:
logger_name: str | None = None,
extra: Mapping[str, Any] | None = None,
) -> None:
"""Send an error log message."""
"""Send a `ERROR`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`."""
await self.log(
level="error", message=message, logger_name=logger_name, extra=extra
level="error",
message=message,
logger_name=logger_name,
extra=extra,
)
async def list_roots(self) -> list[Root]:
@ -675,3 +719,31 @@ def _parse_model_preferences(
raise ValueError(
"model_preferences must be one of: ModelPreferences, str, list[str], or None."
)
async def _log_to_server_and_client(
data: LogData,
session: ServerSession,
level: LoggingLevel,
logger_name: str | None = None,
related_request_id: str | None = None,
) -> None:
"""Log a message to the server and client."""
msg_prefix = f"Sending {level.upper()} to client"
if logger_name:
msg_prefix += f" ({logger_name})"
to_client_logger.log(
level=_mcp_level_to_python_level[level],
msg=f"{msg_prefix}: {data.msg}",
extra=data.extra,
)
await session.send_log_message(
level=level,
data=data,
logger=logger_name,
related_request_id=related_request_id,
)

View file

@ -224,11 +224,17 @@ def create_sse_app(
if middleware:
server_middleware.extend(middleware)
@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
async with server._lifespan_manager():
yield
# Create and return the app
app = create_base_app(
routes=server_routes,
middleware=server_middleware,
debug=debug,
lifespan=lifespan,
)
# Store the FastMCP server instance on the Starlette app state
app.state.fastmcp_server = server
@ -320,8 +326,9 @@ def create_streamable_http_app(
# Create a lifespan manager to start and stop the session manager
@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
async with session_manager.run():
yield
async with server._lifespan_manager():
async with session_manager.run():
yield
# Create and return the app with lifespan
app = create_base_app(

View file

@ -89,6 +89,10 @@ Transport = Literal["stdio", "http", "sse", "streamable-http"]
# Compiled URI parsing regex to split a URI into protocol and path components
URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$")
LifespanCallable = Callable[
["FastMCP[LifespanResultT]"], AbstractAsyncContextManager[LifespanResultT]
]
@asynccontextmanager
async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]:
@ -98,26 +102,31 @@ async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[An
server: The server instance this lifespan is managing
Returns:
An empty context object
An empty dictionary as the lifespan result.
"""
yield {}
def _lifespan_wrapper(
app: FastMCP[LifespanResultT],
lifespan: Callable[
[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
],
def _lifespan_proxy(
fastmcp_server: FastMCP[LifespanResultT],
) -> Callable[
[LowLevelServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
]:
@asynccontextmanager
async def wrap(
s: LowLevelServer[LifespanResultT],
low_level_server: LowLevelServer[LifespanResultT],
) -> AsyncIterator[LifespanResultT]:
async with AsyncExitStack() as stack:
context = await stack.enter_async_context(lifespan(app))
yield context
if fastmcp_server._lifespan is default_lifespan:
yield {}
return
if not fastmcp_server._lifespan_result_set:
raise RuntimeError(
"FastMCP server has a lifespan defined but no lifespan result is set, which means the server's context manager was not entered. "
+ " Are you running the server in a way that supports lifespans? If so, please file an issue at https://github.com/jlowin/fastmcp/issues."
)
yield fastmcp_server._lifespan_result
return wrap
@ -131,13 +140,7 @@ class FastMCP(Generic[LifespanResultT]):
version: str | None = None,
auth: AuthProvider | None | NotSetT = NotSet,
middleware: list[Middleware] | None = None,
lifespan: (
Callable[
[FastMCP[LifespanResultT]],
AbstractAsyncContextManager[LifespanResultT],
]
| None
) = None,
lifespan: LifespanCallable | None = None,
dependencies: list[str] | None = None,
resource_prefix_format: Literal["protocol", "path"] | None = None,
mask_error_details: bool | None = None,
@ -150,6 +153,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 ---
@ -188,18 +192,17 @@ class FastMCP(Generic[LifespanResultT]):
)
self._tool_serializer = tool_serializer
if lifespan is None:
self._has_lifespan = False
lifespan = default_lifespan
else:
self._has_lifespan = True
self._lifespan: LifespanCallable[LifespanResultT] = lifespan or default_lifespan
self._lifespan_result: LifespanResultT | None = None
self._lifespan_result_set = False
# Generate random ID if no name provided
self._mcp_server = LowLevelServer[LifespanResultT](
fastmcp=self,
name=name or self.generate_name(),
version=version or fastmcp.__version__,
instructions=instructions,
lifespan=_lifespan_wrapper(self, lifespan),
lifespan=_lifespan_proxy(fastmcp_server=self),
)
# if auth is `NotSet`, try to create a provider from the environment
@ -219,6 +222,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 []
@ -334,6 +342,27 @@ class FastMCP(Generic[LifespanResultT]):
def version(self) -> str | None:
return self._mcp_server.version
@asynccontextmanager
async def _lifespan_manager(self) -> AsyncIterator[None]:
if self._lifespan_result_set:
yield
return
async with self._lifespan(self) as lifespan_result:
self._lifespan_result = lifespan_result
self._lifespan_result_set = True
async with AsyncExitStack[bool | None]() as stack:
for server in self._mounted_servers:
await stack.enter_async_context(
cm=server.server._lifespan_manager()
)
yield
self._lifespan_result_set = False
self._lifespan_result = None
async def run_async(
self,
transport: Transport | None = None,
@ -391,7 +420,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)
@ -1880,15 +1911,18 @@ class FastMCP(Generic[LifespanResultT]):
)
with temporary_log_level(log_level):
async with stdio_server() as (read_stream, write_stream):
logger.info(f"Starting MCP server {self.name!r} with transport 'stdio'")
await self._mcp_server.run(
read_stream,
write_stream,
self._mcp_server.create_initialization_options(
NotificationOptions(tools_changed=True)
),
)
async with self._lifespan_manager():
async with stdio_server() as (read_stream, write_stream):
logger.info(
f"Starting MCP server {self.name!r} with transport 'stdio'"
)
await self._mcp_server.run(
read_stream,
write_stream,
self._mcp_server.create_initialization_options(
NotificationOptions(tools_changed=True)
),
)
async def run_http_async(
self,
@ -1959,14 +1993,15 @@ class FastMCP(Generic[LifespanResultT]):
config_kwargs["log_level"] = default_log_level_to_use
with temporary_log_level(log_level):
config = uvicorn.Config(app, host=host, port=port, **config_kwargs)
server = uvicorn.Server(config)
path = app.state.path.lstrip("/") # type: ignore
logger.info(
f"Starting MCP server {self.name!r} with transport {transport!r} on http://{host}:{port}/{path}"
)
async with self._lifespan_manager():
config = uvicorn.Config(app, host=host, port=port, **config_kwargs)
server = uvicorn.Server(config)
path = app.state.path.lstrip("/") # type: ignore
logger.info(
f"Starting MCP server {self.name!r} with transport {transport!r} on http://{host}:{port}/{path}"
)
await server.serve()
await server.serve()
async def run_sse_async(
self,
@ -2228,7 +2263,7 @@ class FastMCP(Generic[LifespanResultT]):
# if as_proxy is not specified and the server has a custom lifespan,
# we should treat it as a proxy
if as_proxy is None:
as_proxy = server._has_lifespan
as_proxy = server._lifespan != default_lifespan
if as_proxy and not isinstance(server, FastMCPProxy):
server = FastMCP.as_proxy(server)
@ -2362,6 +2397,15 @@ class FastMCP(Generic[LifespanResultT]):
prompt = prompt.model_copy(key=f"{prefix}_{key}")
self._prompt_manager.add_prompt(prompt)
if server._lifespan != default_lifespan:
from warnings import warn
warn(
message="When importing from a server with a lifespan, the lifespan from the imported server will not be used.",
category=RuntimeWarning,
stacklevel=2,
)
if prefix:
logger.debug(
f"[{self.name}] Imported server {server.name} with prefix '{prefix}'"

View file

@ -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.

View file

@ -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

View file

@ -6,6 +6,7 @@ from typing import Any, Literal, cast
from rich.console import Console
from rich.logging import RichHandler
from typing_extensions import override
import fastmcp
@ -19,7 +20,10 @@ def get_logger(name: str) -> logging.Logger:
Returns:
a configured logger instance
"""
return logging.getLogger(f"fastmcp.{name}")
if name.startswith("fastmcp."):
return logging.getLogger(name=name)
return logging.getLogger(name=f"fastmcp.{name}")
def configure_logging(
@ -141,3 +145,86 @@ def temporary_log_level(
)
else:
yield
class _ClampedLogFilter(logging.Filter):
min_level: tuple[int, str] | None
max_level: tuple[int, str] | None
def __init__(
self,
min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
| None = None,
max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
| None = None,
):
self.min_level = None
self.max_level = None
if min_level_no := self._level_to_no(level=min_level):
self.min_level = (min_level_no, str(min_level))
if max_level_no := self._level_to_no(level=max_level):
self.max_level = (max_level_no, str(max_level))
super().__init__()
def _level_to_no(
self, level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None
) -> int | None:
if level == "DEBUG":
return logging.DEBUG
elif level == "INFO":
return logging.INFO
elif level == "WARNING":
return logging.WARNING
elif level == "ERROR":
return logging.ERROR
elif level == "CRITICAL":
return logging.CRITICAL
else:
return None
@override
def filter(self, record: logging.LogRecord) -> bool:
if self.max_level:
max_level_no, max_level_name = self.max_level
if record.levelno > max_level_no:
record.levelno = max_level_no
record.levelname = max_level_name
return True
if self.min_level:
min_level_no, min_level_name = self.min_level
if record.levelno < min_level_no:
record.levelno = min_level_no
record.levelname = min_level_name
return True
return True
def _clamp_logger(
logger: logging.Logger,
min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
) -> None:
"""Clamp the logger to a minimum and maximum level.
If min_level is provided, messages logged at a lower level than `min_level` will have their level increased to `min_level`.
If max_level is provided, messages logged at a higher level than `max_level` will have their level decreased to `max_level`.
Args:
min_level: The lower bound of the clamp
max_level: The upper bound of the clamp
"""
_unclamp_logger(logger=logger)
logger.addFilter(filter=_ClampedLogFilter(min_level=min_level, max_level=max_level))
def _unclamp_logger(logger: logging.Logger) -> None:
"""Remove all clamped log filters from the logger."""
for filter in logger.filters[:]:
if isinstance(filter, _ClampedLogFilter):
logger.removeFilter(filter)

View file

@ -274,6 +274,210 @@ mcp = fastmcp.FastMCP("TestServer")
assert exc_info.value.code == 1
class TestV1ServerAsync:
"""Test FastMCP 1.x server async support."""
async def test_run_v1_server_stdio(self, tmp_path):
"""Test that v1 server uses async stdio method."""
from unittest.mock import AsyncMock, patch
from mcp.server.fastmcp import FastMCP as FastMCP1x
from fastmcp.cli.run import run_command
# Create a v1 FastMCP server file with both sync and async tools
test_file = tmp_path / "v1_server.py"
test_file.write_text("""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("V1Server")
@mcp.tool()
def sync_echo(text: str) -> str:
'''Sync tool for testing'''
return f"sync: {text}"
@mcp.tool()
async def async_echo(text: str) -> str:
'''Async tool for testing'''
return f"async: {text}"
""")
# Mock the async run method
with patch.object(
FastMCP1x, "run_stdio_async", new_callable=AsyncMock
) as run_mock:
await run_command(str(test_file), transport="stdio")
run_mock.assert_called_once()
async def test_run_v1_server_http(self, tmp_path):
"""Test that v1 server uses async http method."""
from unittest.mock import AsyncMock, patch
from mcp.server.fastmcp import FastMCP as FastMCP1x
from fastmcp.cli.run import run_command
# Create a v1 FastMCP server file with both sync and async tools
test_file = tmp_path / "v1_server.py"
test_file.write_text("""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("V1Server")
@mcp.tool()
def sync_echo(text: str) -> str:
'''Sync tool for testing'''
return f"sync: {text}"
@mcp.tool()
async def async_echo(text: str) -> str:
'''Async tool for testing'''
return f"async: {text}"
""")
# Mock the async run method
with patch.object(
FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock
) as run_mock:
await run_command(str(test_file), transport="http")
run_mock.assert_called_once()
async def test_run_v1_server_streamable_http(self, tmp_path):
"""Test that v1 server uses async streamable-http method."""
from unittest.mock import AsyncMock, patch
from mcp.server.fastmcp import FastMCP as FastMCP1x
from fastmcp.cli.run import run_command
# Create a v1 FastMCP server file with both sync and async tools
test_file = tmp_path / "v1_server.py"
test_file.write_text("""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("V1Server")
@mcp.tool()
def sync_echo(text: str) -> str:
'''Sync tool for testing'''
return f"sync: {text}"
@mcp.tool()
async def async_echo(text: str) -> str:
'''Async tool for testing'''
return f"async: {text}"
""")
# Mock the async run method
with patch.object(
FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock
) as run_mock:
await run_command(str(test_file), transport="streamable-http")
run_mock.assert_called_once()
async def test_run_v1_server_sse(self, tmp_path):
"""Test that v1 server uses async sse method."""
from unittest.mock import AsyncMock, patch
from mcp.server.fastmcp import FastMCP as FastMCP1x
from fastmcp.cli.run import run_command
# Create a v1 FastMCP server file with both sync and async tools
test_file = tmp_path / "v1_server.py"
test_file.write_text("""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("V1Server")
@mcp.tool()
def sync_echo(text: str) -> str:
'''Sync tool for testing'''
return f"sync: {text}"
@mcp.tool()
async def async_echo(text: str) -> str:
'''Async tool for testing'''
return f"async: {text}"
""")
# Mock the async run method
with patch.object(
FastMCP1x, "run_sse_async", new_callable=AsyncMock
) as run_mock:
await run_command(str(test_file), transport="sse")
run_mock.assert_called_once()
async def test_run_v1_server_default_transport(self, tmp_path):
"""Test that v1 server uses streamable-http by default."""
from unittest.mock import AsyncMock, patch
from mcp.server.fastmcp import FastMCP as FastMCP1x
from fastmcp.cli.run import run_command
# Create a v1 FastMCP server file with both sync and async tools
test_file = tmp_path / "v1_server.py"
test_file.write_text("""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("V1Server")
@mcp.tool()
def sync_echo(text: str) -> str:
'''Sync tool for testing'''
return f"sync: {text}"
@mcp.tool()
async def async_echo(text: str) -> str:
'''Async tool for testing'''
return f"async: {text}"
""")
# Mock the async run method
with patch.object(
FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock
) as run_mock:
await run_command(str(test_file))
run_mock.assert_called_once()
async def test_run_v1_server_with_host_port(self, tmp_path):
"""Test that v1 server receives host/port settings."""
from unittest.mock import AsyncMock, patch
from mcp.server.fastmcp import FastMCP as FastMCP1x
from fastmcp.cli.run import run_command
# Create a v1 FastMCP server file with both sync and async tools
test_file = tmp_path / "v1_server.py"
test_file.write_text("""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("V1Server")
@mcp.tool()
def sync_echo(text: str) -> str:
'''Sync tool for testing'''
return f"sync: {text}"
@mcp.tool()
async def async_echo(text: str) -> str:
'''Async tool for testing'''
return f"async: {text}"
""")
# Mock the async run method
with patch.object(
FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock
) as run_mock:
await run_command(
str(test_file), transport="http", host="0.0.0.0", port=9000
)
run_mock.assert_called_once()
class TestSkipSource:
"""Test the --skip-source functionality."""

View file

@ -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")

View file

@ -101,7 +101,7 @@ class TestDefaultLogHandler:
from fastmcp.client.logging import default_log_handler
with patch("fastmcp.client.logging.logger") as mock_logger:
with patch("fastmcp.client.logging.from_server_logger") as mock_logger:
# Set up mock methods
mock_logger.debug = MagicMock()
mock_logger.info = MagicMock()
@ -141,7 +141,8 @@ class TestDefaultLogHandler:
# Verify correct method was called
expected_method.assert_called_once_with(
f"Server log: [test.logger] {msg}", extra={"test_key": "test_value"}
msg=f"Received {level.upper()} from server (test.logger): {msg}",
extra={"test_key": "test_value"},
)
async def test_default_handler_without_logger_name(self):
@ -152,7 +153,7 @@ class TestDefaultLogHandler:
from fastmcp.client.logging import default_log_handler
with patch("fastmcp.client.logging.logger") as mock_logger:
with patch("fastmcp.client.logging.from_server_logger") as mock_logger:
mock_logger.info = MagicMock()
log_msg = LoggingMessageNotificationParams(
@ -164,7 +165,7 @@ class TestDefaultLogHandler:
await default_log_handler(log_msg)
mock_logger.info.assert_called_once_with(
"Server log: Message without logger", extra={}
msg="Received INFO from server: Message without logger", extra={}
)
async def test_default_handler_with_missing_msg(self):
@ -175,7 +176,7 @@ class TestDefaultLogHandler:
from fastmcp.client.logging import default_log_handler
with patch("fastmcp.client.logging.logger") as mock_logger:
with patch("fastmcp.client.logging.from_server_logger") as mock_logger:
mock_logger.info = MagicMock()
log_msg = LoggingMessageNotificationParams(
@ -189,5 +190,5 @@ class TestDefaultLogHandler:
# Should use str(message) as fallback
mock_logger.info.assert_called_once()
call_args = mock_logger.info.call_args
assert "Server log:" in call_args[0][0]
assert "Received INFO from server" in call_args[1]["msg"]
assert call_args[1]["extra"] == {"key": "value"}

View file

@ -92,7 +92,8 @@ async def test_http_headers(sse_server: str):
def run_nested_server(host: str, port: int) -> None:
app = fastmcp_server().sse_app(path="/mcp/sse/", message_path="/mcp/messages")
fastmcp = fastmcp_server()
app = fastmcp.sse_app(path="/mcp/sse/", message_path="/mcp/messages")
mount = Starlette(routes=[Mount("/nest-inner", app=app)])
mount2 = Starlette(routes=[Mount("/nest-outer", app=mount)])
server = uvicorn.Server(

View file

@ -5,7 +5,10 @@ import pytest
from mcp.types import ModelPreferences
from starlette.requests import Request
from fastmcp.server.context import Context, _parse_model_preferences
from fastmcp.server.context import (
Context,
_parse_model_preferences,
)
from fastmcp.server.server import FastMCP

View file

@ -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]

View file

@ -888,15 +888,18 @@ class TestAsProxyKwarg:
assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy)
async def test_as_proxy_defaults_true_if_lifespan(self):
"""Test that as_proxy defaults to True when server_lifespan is provided."""
@asynccontextmanager
async def lifespan(mcp: FastMCP):
async def server_lifespan(mcp: FastMCP):
yield
mcp = FastMCP("Main")
sub = FastMCP("Sub", lifespan=lifespan)
sub = FastMCP("Sub", lifespan=server_lifespan)
mcp.mount(sub, "sub")
# Should auto-proxy because lifespan is set
assert mcp._mounted_servers[0].server is not sub
assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy)

View file

@ -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()

View file

@ -0,0 +1,68 @@
"""Tests for server_lifespan and session_lifespan behavior."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from fastmcp import Client, FastMCP
from fastmcp.server.context import Context
class TestServerLifespan:
"""Test server_lifespan functionality."""
async def test_server_lifespan_basic(self):
"""Test that server_lifespan is entered once and persists across sessions."""
lifespan_events: list[str] = []
@asynccontextmanager
async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]:
_ = lifespan_events.append("enter")
yield {"initialized": True}
_ = lifespan_events.append("exit")
mcp = FastMCP("TestServer", lifespan=server_lifespan)
@mcp.tool
def get_value() -> str:
return "test"
# Server lifespan should be entered when run_async starts
assert lifespan_events == []
# Connect first client session
async with Client(mcp) as client1:
result1 = await client1.call_tool("get_value", {})
assert result1.data == "test"
# Server lifespan should have been entered once
assert lifespan_events == ["enter"]
# Connect second client session while first is still active
async with Client(mcp) as client2:
result2 = await client2.call_tool("get_value", {})
assert result2.data == "test"
# Server lifespan should still only have been entered once
assert lifespan_events == ["enter"]
# Because we're using a fastmcptransport, the server lifespan should be exited
# when the client session closes
assert lifespan_events == ["enter", "exit"]
async def test_server_lifespan_context_available(self):
"""Test that server_lifespan context is available to tools."""
@asynccontextmanager
async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict]:
yield {"db_connection": "mock_db"}
mcp = FastMCP("TestServer", lifespan=server_lifespan)
@mcp.tool
def get_db_info(ctx: Context) -> str:
# Access the server lifespan context
lifespan_context = ctx.request_context.lifespan_context
return lifespan_context.get("db_connection", "no_db")
async with Client(mcp) as client:
result = await client.call_tool("get_db_info", {})
assert result.data == "mock_db"

View file

@ -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):