mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 04:24:17 +02:00
Merge branch 'main' into bump-kv
This commit is contained in:
commit
5d13deffc9
22 changed files with 1211 additions and 574 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}'"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
353
tests/server/test_input_validation.py
Normal file
353
tests/server/test_input_validation.py
Normal 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]
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
68
tests/server/test_server_lifespan.py
Normal file
68
tests/server/test_server_lifespan.py
Normal 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"
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue