Jeremiah Lowin commited on
Commit
0e1f06a
·
unverified ·
1 Parent(s): 7d758ee

Support string annotations (#1255)

Browse files
docs/servers/tools.mdx CHANGED
@@ -163,9 +163,8 @@ def my_tool() -> None:
163
  ```
164
  </CodeGroup>
165
 
166
- ### Tool Parameters
167
 
168
- #### Type Annotations
169
 
170
  Type annotations for parameters are essential for proper tool functionality. They:
171
  1. Inform the LLM about the expected data types for each parameter
@@ -185,9 +184,55 @@ def analyze_text(
185
  # Implementation...
186
  ```
187
 
188
- #### Parameter Metadata
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
- 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:
 
 
 
 
 
 
 
 
191
 
192
  ```python
193
  from typing import Annotated
@@ -227,26 +272,9 @@ Field provides several validation and documentation features:
227
  - `pattern`: Regex pattern for string validation
228
  - `default`: Default value if parameter is omitted
229
 
230
- #### Supported Types
231
-
232
- FastMCP supports a wide range of type annotations, including all Pydantic types:
233
 
234
- | Type Annotation | Example | Description |
235
- | :---------------------- | :---------------------------- | :---------------------------------- |
236
- | Basic types | `int`, `float`, `str`, `bool` | Simple scalar values - see [Built-in Types](#built-in-types) |
237
- | Binary data | `bytes` | Binary content - see [Binary Data](#binary-data) |
238
- | Date and Time | `datetime`, `date`, `timedelta` | Date and time objects - see [Date and Time Types](#date-and-time-types) |
239
- | Collection types | `list[str]`, `dict[str, int]`, `set[int]` | Collections of items - see [Collection Types](#collection-types) |
240
- | Optional types | `float \| None`, `Optional[float]`| Parameters that may be null/omitted - see [Union and Optional Types](#union-and-optional-types) |
241
- | Union types | `str \| int`, `Union[str, int]`| Parameters accepting multiple types - see [Union and Optional Types](#union-and-optional-types) |
242
- | Constrained types | `Literal["A", "B"]`, `Enum` | Parameters with specific allowed values - see [Constrained Types](#constrained-types) |
243
- | Paths | `Path` | File system paths - see [Paths](#paths) |
244
- | UUIDs | `UUID` | Universally unique identifiers - see [UUIDs](#uuids) |
245
- | Pydantic models | `UserData` | Complex structured data - see [Pydantic Models](#pydantic-models) |
246
-
247
- For additional type annotations not listed here, see the [Parameter Types](#parameter-types) section below for more detailed information and examples.
248
 
249
- #### Optional Arguments
250
 
251
  FastMCP follows Python's standard function parameter conventions. Parameters without default values are required, while those with default values are optional.
252
 
 
163
  ```
164
  </CodeGroup>
165
 
 
166
 
167
+ ### Type Annotations
168
 
169
  Type annotations for parameters are essential for proper tool functionality. They:
170
  1. Inform the LLM about the expected data types for each parameter
 
184
  # Implementation...
185
  ```
186
 
187
+ FastMCP supports a wide range of type annotations, including all Pydantic types:
188
+
189
+ | Type Annotation | Example | Description |
190
+ | :---------------------- | :---------------------------- | :---------------------------------- |
191
+ | Basic types | `int`, `float`, `str`, `bool` | Simple scalar values - see [Built-in Types](#built-in-types) |
192
+ | Binary data | `bytes` | Binary content - see [Binary Data](#binary-data) |
193
+ | Date and Time | `datetime`, `date`, `timedelta` | Date and time objects - see [Date and Time Types](#date-and-time-types) |
194
+ | Collection types | `list[str]`, `dict[str, int]`, `set[int]` | Collections of items - see [Collection Types](#collection-types) |
195
+ | Optional types | `float \| None`, `Optional[float]`| Parameters that may be null/omitted - see [Union and Optional Types](#union-and-optional-types) |
196
+ | Union types | `str \| int`, `Union[str, int]`| Parameters accepting multiple types - see [Union and Optional Types](#union-and-optional-types) |
197
+ | Constrained types | `Literal["A", "B"]`, `Enum` | Parameters with specific allowed values - see [Constrained Types](#constrained-types) |
198
+ | Paths | `Path` | File system paths - see [Paths](#paths) |
199
+ | UUIDs | `UUID` | Universally unique identifiers - see [UUIDs](#uuids) |
200
+ | Pydantic models | `UserData` | Complex structured data - see [Pydantic Models](#pydantic-models) |
201
+
202
+ For additional type annotations not listed here, see the [Parameter Types](#parameter-types) section below for more detailed information and examples.
203
+ ### Parameter Metadata
204
+
205
+ You can provide additional metadata about parameters in several ways:
206
+
207
+ #### Simple String Descriptions
208
+
209
+ <VersionBadge version="2.11.0" />
210
+
211
+ For basic parameter descriptions, you can use a convenient shorthand with `Annotated`:
212
+
213
+ ```python
214
+ from typing import Annotated
215
+
216
+ @mcp.tool
217
+ def process_image(
218
+ image_url: Annotated[str, "URL of the image to process"],
219
+ resize: Annotated[bool, "Whether to resize the image"] = False,
220
+ width: Annotated[int, "Target width in pixels"] = 800,
221
+ format: Annotated[str, "Output image format"] = "jpeg"
222
+ ) -> dict:
223
+ """Process an image with optional resizing."""
224
+ # Implementation...
225
+ ```
226
 
227
+ This shorthand syntax is equivalent to using `Field(description=...)` but more concise for simple descriptions.
228
+
229
+ <Tip>
230
+ This shorthand syntax is only applied to `Annotated` types with a single string description.
231
+ </Tip>
232
+
233
+ #### Advanced Metadata with Field
234
+
235
+ For validation constraints and advanced metadata, use Pydantic's `Field` class with `Annotated`:
236
 
237
  ```python
238
  from typing import Annotated
 
272
  - `pattern`: Regex pattern for string validation
273
  - `default`: Default value if parameter is omitted
274
 
 
 
 
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
277
+ ### Optional Arguments
278
 
279
  FastMCP follows Python's standard function parameter conventions. Parameters without default values are required, while those with default values are optional.
280
 
src/fastmcp/utilities/types.py CHANGED
@@ -20,7 +20,7 @@ from typing import (
20
 
21
  import mcp.types
22
  from mcp.types import Annotations
23
- from pydantic import AnyUrl, BaseModel, ConfigDict, TypeAdapter, UrlConstraints
24
 
25
  T = TypeVar("T")
26
 
@@ -43,53 +43,65 @@ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
43
  However, this isn't feasible for user-generated functions. Instead, we use a
44
  cache to minimize the cost of creating them as much as possible.
45
  """
46
- # For functions, we need to ensure TypeAdapter can resolve forward
47
- # references
48
- # Normally this could be done by setting e.g. parent_depth=3 to reflect the
49
- # globals in the parent stack, but this utility function can't make that assumption.
50
  if inspect.isfunction(cls) or inspect.ismethod(cls):
51
- # Only try to resolve annotations if the function has them
52
  if hasattr(cls, "__annotations__") and cls.__annotations__:
53
  try:
54
- # Use include_extras=True to preserve Annotated metadata
55
  resolved_hints = get_type_hints(cls, include_extras=True)
56
- # Check if we need to create a new function with resolved annotations
57
- if resolved_hints != cls.__annotations__:
58
- # Create a new function object with resolved annotations
59
- import types
60
-
61
- # Handle both functions and methods
62
- if inspect.ismethod(cls):
63
- actual_func = cls.__func__
64
- code = actual_func.__code__
65
- globals_dict = actual_func.__globals__
66
- name = actual_func.__name__
67
- defaults = actual_func.__defaults__
68
- closure = actual_func.__closure__
69
- else:
70
- code = cls.__code__
71
- globals_dict = cls.__globals__
72
- name = cls.__name__
73
- defaults = cls.__defaults__
74
- closure = cls.__closure__
75
-
76
- new_func = types.FunctionType(
77
- code,
78
- globals_dict,
79
- name,
80
- defaults,
81
- closure,
82
- )
83
- new_func.__dict__.update(cls.__dict__)
84
- new_func.__module__ = cls.__module__
85
- new_func.__qualname__ = getattr(cls, "__qualname__", cls.__name__)
86
- new_func.__annotations__ = resolved_hints
87
- return TypeAdapter(new_func)
88
  except Exception:
89
- # If resolution fails, this might be due to closure-scoped types
90
- # that aren't available in the function's globals. In this case,
91
- # we'll let TypeAdapter handle the string annotations directly.
92
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  return TypeAdapter(cls)
95
 
 
20
 
21
  import mcp.types
22
  from mcp.types import Annotations
23
+ from pydantic import AnyUrl, BaseModel, ConfigDict, Field, TypeAdapter, UrlConstraints
24
 
25
  T = TypeVar("T")
26
 
 
43
  However, this isn't feasible for user-generated functions. Instead, we use a
44
  cache to minimize the cost of creating them as much as possible.
45
  """
46
+ # For functions, process annotations to handle forward references and convert
47
+ # Annotated[Type, "string"] to Annotated[Type, Field(description="string")]
 
 
48
  if inspect.isfunction(cls) or inspect.ismethod(cls):
 
49
  if hasattr(cls, "__annotations__") and cls.__annotations__:
50
  try:
51
+ # Resolve forward references first
52
  resolved_hints = get_type_hints(cls, include_extras=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  except Exception:
54
+ # If forward reference resolution fails, use original annotations
55
+ resolved_hints = cls.__annotations__
56
+
57
+ # Process annotations to convert string descriptions to Fields
58
+ processed_hints = {}
59
+
60
+ for name, annotation in resolved_hints.items():
61
+ # Check if this is Annotated[Type, "string"] and convert to Annotated[Type, Field(description="string")]
62
+ if (
63
+ get_origin(annotation) is Annotated
64
+ and len(get_args(annotation)) == 2
65
+ and isinstance(get_args(annotation)[1], str)
66
+ ):
67
+ base_type, description = get_args(annotation)
68
+ processed_hints[name] = Annotated[
69
+ base_type, Field(description=description)
70
+ ]
71
+ else:
72
+ processed_hints[name] = annotation
73
+
74
+ # Create new function if annotations changed
75
+ if processed_hints != cls.__annotations__:
76
+ import types
77
+
78
+ # Handle both functions and methods
79
+ if inspect.ismethod(cls):
80
+ actual_func = cls.__func__
81
+ code = actual_func.__code__
82
+ globals_dict = actual_func.__globals__
83
+ name = actual_func.__name__
84
+ defaults = actual_func.__defaults__
85
+ closure = actual_func.__closure__
86
+ else:
87
+ code = cls.__code__
88
+ globals_dict = cls.__globals__
89
+ name = cls.__name__
90
+ defaults = cls.__defaults__
91
+ closure = cls.__closure__
92
+
93
+ new_func = types.FunctionType(
94
+ code,
95
+ globals_dict,
96
+ name,
97
+ defaults,
98
+ closure,
99
+ )
100
+ new_func.__dict__.update(cls.__dict__)
101
+ new_func.__module__ = cls.__module__
102
+ new_func.__qualname__ = getattr(cls, "__qualname__", cls.__name__)
103
+ new_func.__annotations__ = processed_hints
104
+ return TypeAdapter(new_func)
105
 
106
  return TypeAdapter(cls)
107
 
tests/server/test_server_interactions.py CHANGED
@@ -891,6 +891,18 @@ class TestToolParameters:
891
  ):
892
  await client.call_tool("send_timedelta", {"x": 1000})
893
 
 
 
 
 
 
 
 
 
 
 
 
 
894
 
895
  class TestToolOutputSchema:
896
  @pytest.mark.parametrize("annotation", [str, int, float, bool, list, AnyUrl])
 
891
  ):
892
  await client.call_tool("send_timedelta", {"x": 1000})
893
 
894
+ async def test_annotated_string_description(self):
895
+ mcp = FastMCP()
896
+
897
+ @mcp.tool
898
+ def f(x: Annotated[int, "A number"]):
899
+ return x
900
+
901
+ async with Client(mcp) as client:
902
+ tools = await client.list_tools()
903
+ assert len(tools) == 1
904
+ assert tools[0].inputSchema["properties"]["x"]["description"] == "A number"
905
+
906
 
907
  class TestToolOutputSchema:
908
  @pytest.mark.parametrize("annotation", [str, int, float, bool, list, AnyUrl])
tests/utilities/test_types.py CHANGED
@@ -7,12 +7,14 @@ from typing import Annotated, Any
7
 
8
  import pytest
9
  from mcp.types import BlobResourceContents, TextResourceContents
 
10
 
11
  from fastmcp.utilities.types import (
12
  Audio,
13
  File,
14
  Image,
15
  find_kwarg_by_type,
 
16
  is_class_member_of_type,
17
  issubclass_safe,
18
  replace_type,
@@ -617,3 +619,76 @@ class TestReplaceType:
617
  def test_replace_type(self, input, type_map, expected):
618
  """Test replacing a type with another type."""
619
  assert replace_type(input, type_map) == expected
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  import pytest
9
  from mcp.types import BlobResourceContents, TextResourceContents
10
+ from pydantic import Field
11
 
12
  from fastmcp.utilities.types import (
13
  Audio,
14
  File,
15
  Image,
16
  find_kwarg_by_type,
17
+ get_cached_typeadapter,
18
  is_class_member_of_type,
19
  issubclass_safe,
20
  replace_type,
 
619
  def test_replace_type(self, input, type_map, expected):
620
  """Test replacing a type with another type."""
621
  assert replace_type(input, type_map) == expected
622
+
623
+
624
+ class TestAnnotationStringDescriptions:
625
+ """Test the new functionality for string descriptions in Annotated types."""
626
+
627
+ def test_get_cached_typeadapter_with_string_descriptions(self):
628
+ """Test TypeAdapter creation with string descriptions."""
629
+
630
+ def func(name: Annotated[str, "The user's name"]) -> str:
631
+ return f"Hello {name}"
632
+
633
+ adapter = get_cached_typeadapter(func)
634
+ schema = adapter.json_schema()
635
+
636
+ # Should have description in schema
637
+ assert "properties" in schema
638
+ assert "name" in schema["properties"]
639
+ assert schema["properties"]["name"]["description"] == "The user's name"
640
+
641
+ def test_multiple_string_annotations(self):
642
+ """Test function with multiple string-annotated parameters."""
643
+
644
+ def func(
645
+ name: Annotated[str, "User's name"],
646
+ email: Annotated[str, "User's email"],
647
+ age: int,
648
+ ) -> str:
649
+ return f"{name} ({email}) is {age}"
650
+
651
+ adapter = get_cached_typeadapter(func)
652
+ schema = adapter.json_schema()
653
+
654
+ # Both annotated parameters should have descriptions
655
+ assert schema["properties"]["name"]["description"] == "User's name"
656
+ assert schema["properties"]["email"]["description"] == "User's email"
657
+ # Non-annotated parameter should not have description
658
+ assert "description" not in schema["properties"]["age"]
659
+
660
+ def test_annotated_with_more_than_string_unchanged(self):
661
+ """Test that Annotated with more than just a string is unchanged."""
662
+
663
+ def func(name: Annotated[str, "desc", "extra"]) -> str:
664
+ return f"Hello {name}"
665
+
666
+ adapter = get_cached_typeadapter(func)
667
+ schema = adapter.json_schema()
668
+
669
+ # Should not have description since it's not exactly length 2
670
+ assert "description" not in schema["properties"]["name"]
671
+
672
+ def test_annotated_with_non_string_unchanged(self):
673
+ """Test that Annotated with non-string second arg is unchanged."""
674
+
675
+ def func(name: Annotated[str, 42]) -> str:
676
+ return f"Hello {name}"
677
+
678
+ adapter = get_cached_typeadapter(func)
679
+ schema = adapter.json_schema()
680
+
681
+ # Should not have description since second arg is not string
682
+ assert "description" not in schema["properties"]["name"]
683
+
684
+ def test_existing_field_unchanged(self):
685
+ """Test that existing Field annotations are unchanged."""
686
+
687
+ def func(name: Annotated[str, Field(description="Field desc")]) -> str:
688
+ return f"Hello {name}"
689
+
690
+ adapter = get_cached_typeadapter(func)
691
+ schema = adapter.json_schema()
692
+
693
+ # Should keep the Field description
694
+ assert schema["properties"]["name"]["description"] == "Field desc"