mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
Support string annotations (#1255)
This commit is contained in:
parent
d19166d4bf
commit
d04840560a
4 changed files with 192 additions and 65 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue