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"