diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index fda4d24af..61d70521c 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -163,9 +163,8 @@ def my_tool() -> None: ``` -### 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 + + + +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. + + +This shorthand syntax is only applied to `Annotated` types with a single string description. + + +#### 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. diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index 815e1faba..5b2d22fe1 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -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) diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index a04fa2515..e03fbb51f 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -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]) diff --git a/tests/utilities/test_types.py b/tests/utilities/test_types.py index a08e5b6b2..926848b53 100644 --- a/tests/utilities/test_types.py +++ b/tests/utilities/test_types.py @@ -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"