Jeremiah Lowin commited on
Commit
3ab4db9
·
unverified ·
1 Parent(s): 8ab933c

Support from __future__ import annotations (#1199)

Browse files
src/fastmcp/tools/tool.py CHANGED
@@ -3,7 +3,15 @@ from __future__ import annotations
3
  import inspect
4
  from collections.abc import Callable
5
  from dataclasses import dataclass
6
- from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, TypeVar
 
 
 
 
 
 
 
 
7
 
8
  import mcp.types
9
  import pydantic_core
@@ -371,7 +379,20 @@ class ParsedFunction:
371
  input_schema = compress_schema(input_schema, prune_params=prune_params)
372
 
373
  output_schema = None
374
- output_type = inspect.signature(fn).return_annotation
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
  if output_type not in (inspect._empty, None, Any, ...):
377
  # there are a variety of types that we don't want to attempt to
 
3
  import inspect
4
  from collections.abc import Callable
5
  from dataclasses import dataclass
6
+ from typing import (
7
+ TYPE_CHECKING,
8
+ Annotated,
9
+ Any,
10
+ Generic,
11
+ Literal,
12
+ TypeVar,
13
+ get_type_hints,
14
+ )
15
 
16
  import mcp.types
17
  import pydantic_core
 
379
  input_schema = compress_schema(input_schema, prune_params=prune_params)
380
 
381
  output_schema = None
382
+ # Get the return annotation from the signature
383
+ sig = inspect.signature(fn)
384
+ output_type = sig.return_annotation
385
+
386
+ # If the annotation is a string (from __future__ annotations), resolve it
387
+ if isinstance(output_type, str):
388
+ try:
389
+ # Use get_type_hints to resolve the return type
390
+ # include_extras=True preserves Annotated metadata
391
+ type_hints = get_type_hints(fn, include_extras=True)
392
+ output_type = type_hints.get("return", output_type)
393
+ except Exception:
394
+ # If resolution fails, keep the string annotation
395
+ pass
396
 
397
  if output_type not in (inspect._empty, None, Any, ...):
398
  # there are a variety of types that we don't want to attempt to
src/fastmcp/utilities/types.py CHANGED
@@ -8,7 +8,15 @@ from collections.abc import Callable
8
  from functools import lru_cache
9
  from pathlib import Path
10
  from types import EllipsisType, UnionType
11
- from typing import Annotated, TypeAlias, TypeVar, Union, get_args, get_origin
 
 
 
 
 
 
 
 
12
 
13
  import mcp.types
14
  from mcp.types import Annotations
@@ -35,6 +43,54 @@ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
35
  However, this isn't feasible for user-generated functions. Instead, we use a
36
  cache to minimize the cost of creating them as much as possible.
37
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  return TypeAdapter(cls)
39
 
40
 
@@ -77,12 +133,21 @@ def find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None:
77
  Includes union types that contain the kwarg_type, as well as Annotated types.
78
  """
79
  if inspect.ismethod(fn) and hasattr(fn, "__func__"):
80
- sig = inspect.signature(fn.__func__)
81
- else:
82
- sig = inspect.signature(fn)
 
 
 
 
 
 
83
 
 
84
  for name, param in sig.parameters.items():
85
- if is_class_member_of_type(param.annotation, kwarg_type):
 
 
86
  return name
87
  return None
88
 
 
8
  from functools import lru_cache
9
  from pathlib import Path
10
  from types import EllipsisType, UnionType
11
+ from typing import (
12
+ Annotated,
13
+ TypeAlias,
14
+ TypeVar,
15
+ Union,
16
+ get_args,
17
+ get_origin,
18
+ get_type_hints,
19
+ )
20
 
21
  import mcp.types
22
  from mcp.types import Annotations
 
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
 
96
 
 
133
  Includes union types that contain the kwarg_type, as well as Annotated types.
134
  """
135
  if inspect.ismethod(fn) and hasattr(fn, "__func__"):
136
+ fn = fn.__func__
137
+
138
+ # Try to get resolved type hints
139
+ try:
140
+ # Use include_extras=True to preserve Annotated metadata
141
+ type_hints = get_type_hints(fn, include_extras=True)
142
+ except Exception:
143
+ # If resolution fails, use raw annotations if they exist
144
+ type_hints = getattr(fn, "__annotations__", {})
145
 
146
+ sig = inspect.signature(fn)
147
  for name, param in sig.parameters.items():
148
+ # Use resolved hint if available, otherwise raw annotation
149
+ annotation = type_hints.get(name, param.annotation)
150
+ if is_class_member_of_type(annotation, kwarg_type):
151
  return name
152
  return None
153
 
tests/server/test_server_interactions.py CHANGED
@@ -31,6 +31,20 @@ from fastmcp.utilities.json_schema import compress_schema
31
  from fastmcp.utilities.types import Audio, File, Image
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  class PersonTypedDict(TypedDict):
35
  name: str
36
  age: int
@@ -917,7 +931,12 @@ class TestToolOutputSchema:
917
 
918
  type_schema = compress_schema(TypeAdapter(annotation).json_schema())
919
  assert len(tools) == 1
920
- assert tools[0].outputSchema == type_schema
 
 
 
 
 
921
 
922
  async def test_disabled_output_schema_no_structured_content(self):
923
  mcp = FastMCP()
 
31
  from fastmcp.utilities.types import Audio, File, Image
32
 
33
 
34
+ def _normalize_anyof_order(schema):
35
+ """Normalize the order of items in anyOf arrays for consistent comparison."""
36
+ if isinstance(schema, dict):
37
+ if "anyOf" in schema:
38
+ # Sort anyOf items by their string representation for consistent ordering
39
+ schema = schema.copy()
40
+ schema["anyOf"] = sorted(schema["anyOf"], key=str)
41
+ # Recursively normalize nested objects
42
+ return {k: _normalize_anyof_order(v) for k, v in schema.items()}
43
+ elif isinstance(schema, list):
44
+ return [_normalize_anyof_order(item) for item in schema]
45
+ return schema
46
+
47
+
48
  class PersonTypedDict(TypedDict):
49
  name: str
50
  age: int
 
931
 
932
  type_schema = compress_schema(TypeAdapter(annotation).json_schema())
933
  assert len(tools) == 1
934
+
935
+ # Normalize anyOf ordering for comparison since union type order
936
+ # can vary between environments when using annotation resolution
937
+ actual_schema = _normalize_anyof_order(tools[0].outputSchema)
938
+ expected_schema = _normalize_anyof_order(type_schema)
939
+ assert actual_schema == expected_schema
940
 
941
  async def test_disabled_output_schema_no_structured_content(self):
942
  mcp = FastMCP()
tests/tools/test_tool_future_annotations.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, cast
4
+
5
+ import mcp.types
6
+ import pytest
7
+
8
+ from fastmcp import Context, FastMCP
9
+ from fastmcp.client import Client
10
+ from fastmcp.tools.tool import ToolResult
11
+ from fastmcp.utilities.types import Image
12
+
13
+ fastmcp_server = FastMCP()
14
+
15
+
16
+ @fastmcp_server.tool
17
+ def simple_with_context(ctx: Context) -> str:
18
+ """Simple tool with context parameter."""
19
+ return f"Request ID: {ctx.request_id}"
20
+
21
+
22
+ @fastmcp_server.tool
23
+ def complex_types(
24
+ data: dict[str, Any], items: list[int], ctx: Context
25
+ ) -> dict[str, str | int]:
26
+ """Tool with complex type annotations."""
27
+ return {"count": len(items), "request_id": ctx.request_id}
28
+
29
+
30
+ @fastmcp_server.tool
31
+ def optional_context(name: str, ctx: Context | None = None) -> str:
32
+ """Tool with optional context."""
33
+ if ctx:
34
+ return f"Hello {name} from request {ctx.request_id}"
35
+ return f"Hello {name}"
36
+
37
+
38
+ @fastmcp_server.tool
39
+ def union_with_context(value: int | str, ctx: Context) -> ToolResult:
40
+ """Tool returning ToolResult with context."""
41
+ return ToolResult(content=f"Value: {value}, Request: {ctx.request_id}")
42
+
43
+
44
+ @fastmcp_server.tool
45
+ def returns_image(ctx: Context) -> Image:
46
+ """Tool that returns an Image."""
47
+ # Create a simple 1x1 white pixel PNG
48
+ png_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18\xd4c\x00\x00\x00\x00IEND\xaeB`\x82"
49
+ return Image(data=png_data, format="png")
50
+
51
+
52
+ @fastmcp_server.tool
53
+ async def async_with_context(ctx: Context) -> str:
54
+ """Async tool with context."""
55
+ return f"Async request: {ctx.request_id}"
56
+
57
+
58
+ class TestFutureAnnotations:
59
+ async def test_simple_with_context(self):
60
+ async with Client(fastmcp_server) as client:
61
+ result = await client.call_tool("simple_with_context", {})
62
+ assert "Request ID:" in cast(mcp.types.TextContent, result.content[0]).text
63
+
64
+ async def test_complex_types(self):
65
+ async with Client(fastmcp_server) as client:
66
+ result = await client.call_tool(
67
+ "complex_types", {"data": {"key": "value"}, "items": [1, 2, 3]}
68
+ )
69
+ # Check the result is valid JSON with expected values
70
+ import json
71
+
72
+ data = json.loads(cast(mcp.types.TextContent, result.content[0]).text)
73
+ assert data["count"] == 3
74
+ assert "request_id" in data
75
+
76
+ async def test_optional_context(self):
77
+ async with Client(fastmcp_server) as client:
78
+ result = await client.call_tool("optional_context", {"name": "World"})
79
+ assert (
80
+ "Hello World from request"
81
+ in cast(mcp.types.TextContent, result.content[0]).text
82
+ )
83
+
84
+ async def test_union_with_context(self):
85
+ async with Client(fastmcp_server) as client:
86
+ result = await client.call_tool("union_with_context", {"value": 42})
87
+ assert (
88
+ "Value: 42, Request:"
89
+ in cast(mcp.types.TextContent, result.content[0]).text
90
+ )
91
+
92
+ async def test_returns_image(self):
93
+ async with Client(fastmcp_server) as client:
94
+ result = await client.call_tool("returns_image", {})
95
+ assert result.content[0].type == "image"
96
+ assert result.content[0].mimeType == "image/png"
97
+
98
+ async def test_async_with_context(self):
99
+ async with Client(fastmcp_server) as client:
100
+ result = await client.call_tool("async_with_context", {})
101
+ assert (
102
+ "Async request:" in cast(mcp.types.TextContent, result.content[0]).text
103
+ )
104
+
105
+ async def test_modern_union_syntax_works(self):
106
+ """Test that modern | union syntax works with future annotations."""
107
+ # This demonstrates that our solution works with | syntax when types
108
+ # are available in module globals
109
+
110
+ # Define a tool with modern union syntax
111
+ @fastmcp_server.tool
112
+ def modern_union_tool(value: str | int | None) -> str | None:
113
+ """Tool using modern | union syntax throughout."""
114
+ if value is None:
115
+ return None
116
+ return f"processed: {value}"
117
+
118
+ async with Client(fastmcp_server) as client:
119
+ # Test with string
120
+ result = await client.call_tool("modern_union_tool", {"value": "hello"})
121
+ assert (
122
+ "processed: hello"
123
+ in cast(mcp.types.TextContent, result.content[0]).text
124
+ )
125
+
126
+ # Test with int
127
+ result = await client.call_tool("modern_union_tool", {"value": 42})
128
+ assert (
129
+ "processed: 42" in cast(mcp.types.TextContent, result.content[0]).text
130
+ )
131
+
132
+ # Test with None
133
+ result = await client.call_tool("modern_union_tool", {"value": None})
134
+ # When function returns None, FastMCP returns empty content
135
+ assert (
136
+ len(result.content) == 0
137
+ or cast(mcp.types.TextContent, result.content[0]).text == "null"
138
+ )
139
+
140
+
141
+ @pytest.mark.xfail(
142
+ reason="Closure-scoped types cannot be resolved with 'from __future__ import annotations'. "
143
+ "When using future annotations, all type annotations become strings that need to be evaluated "
144
+ "using eval() in the function's global namespace. Types defined only in closure scope "
145
+ "(like local imports or type aliases) are not available in the function's __globals__ "
146
+ "and therefore cannot be resolved by get_type_hints()."
147
+ )
148
+ def test_closure_scoped_types_limitation():
149
+ """
150
+ This test demonstrates that closure-scoped types don't work with future annotations.
151
+
152
+ The fundamental issue is that 'from __future__ import annotations' converts all
153
+ annotations to strings, and those strings can only be resolved using the function's
154
+ global namespace, not local variables from closures.
155
+ """
156
+
157
+ def create_failing_closure():
158
+ # This import is only available in the closure scope
159
+
160
+ mcp = FastMCP()
161
+
162
+ @mcp.tool
163
+ def closure_tool(value: str | None) -> str:
164
+ """This will fail because Optional can't be resolved from closure import."""
165
+ return str(value)
166
+
167
+ return mcp
168
+
169
+ # This should raise an error during tool registration
170
+ create_failing_closure()