Support string annotations (#1255)

This commit is contained in:
Jeremiah Lowin 2025-07-24 11:28:58 -07:00 committed by GitHub
commit d04840560a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 192 additions and 65 deletions

View file

@ -163,9 +163,8 @@ def my_tool() -> None:
```
</CodeGroup>
### Tool Parameters
#### Type Annotations
### 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
@ -185,9 +184,55 @@ def analyze_text(
# Implementation...
```
#### Parameter Metadata
FastMCP supports a wide range of type annotations, including all Pydantic types:
You can provide additional metadata about parameters using Pydantic's `Field` class with `Annotated`. This approach is preferred as it's more modern and keeps type hints separate from validation rules:
| 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) |
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:
#### Simple String Descriptions
<VersionBadge version="2.11.0" />
For basic parameter descriptions, you can use a convenient shorthand with `Annotated`:
```python
from typing import Annotated
@mcp.tool
def process_image(
image_url: Annotated[str, "URL of the image to process"],
resize: Annotated[bool, "Whether to resize the image"] = False,
width: Annotated[int, "Target width in pixels"] = 800,
format: Annotated[str, "Output image format"] = "jpeg"
) -> dict:
"""Process an image with optional resizing."""
# Implementation...
```
This shorthand syntax is equivalent to using `Field(description=...)` but more concise for simple descriptions.
<Tip>
This shorthand syntax is only applied to `Annotated` types with a single string description.
</Tip>
#### Advanced Metadata with Field
For validation constraints and advanced metadata, use Pydantic's `Field` class with `Annotated`:
```python
from typing import Annotated
@ -227,26 +272,9 @@ Field provides several validation and documentation features:
- `pattern`: Regex pattern for string validation
- `default`: Default value if parameter is omitted
#### Supported Types
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) |
For additional type annotations not listed here, see the [Parameter Types](#parameter-types) section below for more detailed information and examples.
#### Optional Arguments
### Optional Arguments
FastMCP follows Python's standard function parameter conventions. Parameters without default values are required, while those with default values are optional.

View file

@ -20,7 +20,7 @@ from typing import (
import mcp.types
from mcp.types import Annotations
from pydantic import AnyUrl, BaseModel, ConfigDict, TypeAdapter, UrlConstraints
from pydantic import AnyUrl, BaseModel, ConfigDict, Field, TypeAdapter, UrlConstraints
T = TypeVar("T")
@ -43,53 +43,65 @@ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
However, this isn't feasible for user-generated functions. Instead, we use a
cache to minimize the cost of creating them as much as possible.
"""
# For functions, we need to ensure TypeAdapter can resolve forward
# references
# Normally this could be done by setting e.g. parent_depth=3 to reflect the
# globals in the parent stack, but this utility function can't make that assumption.
# For functions, process annotations to handle forward references and convert
# Annotated[Type, "string"] to Annotated[Type, Field(description="string")]
if inspect.isfunction(cls) or inspect.ismethod(cls):
# Only try to resolve annotations if the function has them
if hasattr(cls, "__annotations__") and cls.__annotations__:
try:
# Use include_extras=True to preserve Annotated metadata
# Resolve forward references first
resolved_hints = get_type_hints(cls, include_extras=True)
# Check if we need to create a new function with resolved annotations
if resolved_hints != cls.__annotations__:
# Create a new function object with resolved annotations
import types
# Handle both functions and methods
if inspect.ismethod(cls):
actual_func = cls.__func__
code = actual_func.__code__
globals_dict = actual_func.__globals__
name = actual_func.__name__
defaults = actual_func.__defaults__
closure = actual_func.__closure__
else:
code = cls.__code__
globals_dict = cls.__globals__
name = cls.__name__
defaults = cls.__defaults__
closure = cls.__closure__
new_func = types.FunctionType(
code,
globals_dict,
name,
defaults,
closure,
)
new_func.__dict__.update(cls.__dict__)
new_func.__module__ = cls.__module__
new_func.__qualname__ = getattr(cls, "__qualname__", cls.__name__)
new_func.__annotations__ = resolved_hints
return TypeAdapter(new_func)
except Exception:
# If resolution fails, this might be due to closure-scoped types
# that aren't available in the function's globals. In this case,
# we'll let TypeAdapter handle the string annotations directly.
pass
# If forward reference resolution fails, use original annotations
resolved_hints = cls.__annotations__
# Process annotations to convert string descriptions to Fields
processed_hints = {}
for name, annotation in resolved_hints.items():
# Check if this is Annotated[Type, "string"] and convert to Annotated[Type, Field(description="string")]
if (
get_origin(annotation) is Annotated
and len(get_args(annotation)) == 2
and isinstance(get_args(annotation)[1], str)
):
base_type, description = get_args(annotation)
processed_hints[name] = Annotated[
base_type, Field(description=description)
]
else:
processed_hints[name] = annotation
# Create new function if annotations changed
if processed_hints != cls.__annotations__:
import types
# Handle both functions and methods
if inspect.ismethod(cls):
actual_func = cls.__func__
code = actual_func.__code__
globals_dict = actual_func.__globals__
name = actual_func.__name__
defaults = actual_func.__defaults__
closure = actual_func.__closure__
else:
code = cls.__code__
globals_dict = cls.__globals__
name = cls.__name__
defaults = cls.__defaults__
closure = cls.__closure__
new_func = types.FunctionType(
code,
globals_dict,
name,
defaults,
closure,
)
new_func.__dict__.update(cls.__dict__)
new_func.__module__ = cls.__module__
new_func.__qualname__ = getattr(cls, "__qualname__", cls.__name__)
new_func.__annotations__ = processed_hints
return TypeAdapter(new_func)
return TypeAdapter(cls)

View file

@ -891,6 +891,18 @@ class TestToolParameters:
):
await client.call_tool("send_timedelta", {"x": 1000})
async def test_annotated_string_description(self):
mcp = FastMCP()
@mcp.tool
def f(x: Annotated[int, "A number"]):
return x
async with Client(mcp) as client:
tools = await client.list_tools()
assert len(tools) == 1
assert tools[0].inputSchema["properties"]["x"]["description"] == "A number"
class TestToolOutputSchema:
@pytest.mark.parametrize("annotation", [str, int, float, bool, list, AnyUrl])

View file

@ -7,12 +7,14 @@ from typing import Annotated, Any
import pytest
from mcp.types import BlobResourceContents, TextResourceContents
from pydantic import Field
from fastmcp.utilities.types import (
Audio,
File,
Image,
find_kwarg_by_type,
get_cached_typeadapter,
is_class_member_of_type,
issubclass_safe,
replace_type,
@ -617,3 +619,76 @@ class TestReplaceType:
def test_replace_type(self, input, type_map, expected):
"""Test replacing a type with another type."""
assert replace_type(input, type_map) == expected
class TestAnnotationStringDescriptions:
"""Test the new functionality for string descriptions in Annotated types."""
def test_get_cached_typeadapter_with_string_descriptions(self):
"""Test TypeAdapter creation with string descriptions."""
def func(name: Annotated[str, "The user's name"]) -> str:
return f"Hello {name}"
adapter = get_cached_typeadapter(func)
schema = adapter.json_schema()
# Should have description in schema
assert "properties" in schema
assert "name" in schema["properties"]
assert schema["properties"]["name"]["description"] == "The user's name"
def test_multiple_string_annotations(self):
"""Test function with multiple string-annotated parameters."""
def func(
name: Annotated[str, "User's name"],
email: Annotated[str, "User's email"],
age: int,
) -> str:
return f"{name} ({email}) is {age}"
adapter = get_cached_typeadapter(func)
schema = adapter.json_schema()
# Both annotated parameters should have descriptions
assert schema["properties"]["name"]["description"] == "User's name"
assert schema["properties"]["email"]["description"] == "User's email"
# Non-annotated parameter should not have description
assert "description" not in schema["properties"]["age"]
def test_annotated_with_more_than_string_unchanged(self):
"""Test that Annotated with more than just a string is unchanged."""
def func(name: Annotated[str, "desc", "extra"]) -> str:
return f"Hello {name}"
adapter = get_cached_typeadapter(func)
schema = adapter.json_schema()
# Should not have description since it's not exactly length 2
assert "description" not in schema["properties"]["name"]
def test_annotated_with_non_string_unchanged(self):
"""Test that Annotated with non-string second arg is unchanged."""
def func(name: Annotated[str, 42]) -> str:
return f"Hello {name}"
adapter = get_cached_typeadapter(func)
schema = adapter.json_schema()
# Should not have description since second arg is not string
assert "description" not in schema["properties"]["name"]
def test_existing_field_unchanged(self):
"""Test that existing Field annotations are unchanged."""
def func(name: Annotated[str, Field(description="Field desc")]) -> str:
return f"Hello {name}"
adapter = get_cached_typeadapter(func)
schema = adapter.json_schema()
# Should keep the Field description
assert schema["properties"]["name"]["description"] == "Field desc"