diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx index eb1cbaa8e..88a756ebc 100644 --- a/docs/python-sdk/fastmcp-server-dependencies.mdx +++ b/docs/python-sdk/fastmcp-server-dependencies.mdx @@ -213,7 +213,7 @@ Handles: - Async wrapper function without injected parameters -### `resolve_dependencies` +### `resolve_dependencies` ```python resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None] @@ -239,7 +239,7 @@ time, so all injection goes through the unified DI system. which will be filtered out) -### `CurrentContext` +### `CurrentContext` ```python CurrentContext() -> Context @@ -258,7 +258,7 @@ current MCP operation (tool/resource/prompt call). - `RuntimeError`: If no active context found (during resolution) -### `CurrentDocket` +### `CurrentDocket` ```python CurrentDocket() -> Docket @@ -278,7 +278,7 @@ automatically creates for background task scheduling. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentWorker` +### `CurrentWorker` ```python CurrentWorker() -> Worker @@ -298,7 +298,7 @@ automatically creates for background task processing. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentFastMCP` +### `CurrentFastMCP` ```python CurrentFastMCP() -> FastMCP @@ -316,7 +316,7 @@ This dependency provides access to the active FastMCP server. - `RuntimeError`: If no server in context (during resolution) -### `CurrentRequest` +### `CurrentRequest` ```python CurrentRequest() -> Request @@ -336,7 +336,7 @@ current HTTP request. Only available when running over HTTP transports - `RuntimeError`: If no HTTP request in context (e.g., STDIO transport) -### `CurrentHeaders` +### `CurrentHeaders` ```python CurrentHeaders() -> dict[str, str] @@ -353,7 +353,7 @@ safe to use in code that might run over any transport. - A dependency that resolves to a dictionary of header name -> value -### `CurrentAccessToken` +### `CurrentAccessToken` ```python CurrentAccessToken() -> AccessToken @@ -372,7 +372,7 @@ authenticated request. Raises an error if no authentication is present. - `RuntimeError`: If no authenticated user (use get_access_token() for optional) -### `TokenClaim` +### `TokenClaim` ```python TokenClaim(name: str) -> str @@ -406,7 +406,7 @@ Returned by ``get_task_context()`` when running inside a Docket worker. Contains identifiers needed to communicate with the MCP session. -### `ProgressLike` +### `ProgressLike` Protocol for progress tracking interface. @@ -417,7 +417,7 @@ and Docket's Progress (worker context). **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None @@ -426,7 +426,7 @@ current(self) -> int | None Current progress value. -#### `total` +#### `total` ```python total(self) -> int @@ -435,7 +435,7 @@ total(self) -> int Total/target progress value. -#### `message` +#### `message` ```python message(self) -> str | None @@ -444,7 +444,7 @@ message(self) -> str | None Current progress message. -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -453,7 +453,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -462,7 +462,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -471,7 +471,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `InMemoryProgress` +### `InMemoryProgress` In-memory progress tracker for immediate tool execution. @@ -483,25 +483,25 @@ progress doesn't need to be observable across processes. **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None ``` -#### `total` +#### `total` ```python total(self) -> int ``` -#### `message` +#### `message` ```python message(self) -> str | None ``` -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -510,7 +510,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -519,7 +519,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -528,7 +528,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `Progress` +### `Progress` FastMCP Progress dependency that works in both server and worker contexts. diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index acd7fea19..15c8c3124 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -607,15 +607,23 @@ def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]: result = await result return result - # Set wrapper metadata (only parameter annotations, not return type) + # Resolve string annotations (from `from __future__ import annotations`) using + # the original function's module context. The wrapper's __globals__ points to + # this module (dependencies.py) and is read-only, so some Pydantic versions + # can't resolve names like Annotated or Literal from string annotations. + try: + resolved_hints = get_type_hints(fn, include_extras=True) + except Exception: + resolved_hints = getattr(fn, "__annotations__", {}) + wrapper.__signature__ = new_sig # type: ignore[attr-defined] wrapper.__annotations__ = { - k: v - for k, v in getattr(fn, "__annotations__", {}).items() - if k not in exclude and k != "return" + k: v for k, v in resolved_hints.items() if k not in exclude and k != "return" } wrapper.__name__ = getattr(fn, "__name__", "wrapper") wrapper.__doc__ = getattr(fn, "__doc__", None) + wrapper.__module__ = fn.__module__ + wrapper.__qualname__ = getattr(fn, "__qualname__", wrapper.__qualname__) return wrapper diff --git a/tests/tools/test_tool_future_annotations.py b/tests/tools/test_tool_future_annotations.py index a5d9d8c0d..10ca9c57a 100644 --- a/tests/tools/test_tool_future_annotations.py +++ b/tests/tools/test_tool_future_annotations.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import Any, cast +from typing import Annotated, Any, Literal, cast import mcp.types -import pytest +from pydantic import Field from fastmcp import Context, FastMCP from fastmcp.client import Client @@ -55,6 +55,20 @@ async def async_with_context(ctx: Context) -> str: return f"Async request: {ctx.request_id}" +@fastmcp_server.tool +def annotated_with_context( + query: Annotated[str, Field(description="Search query")], ctx: Context +) -> str: + """Tool using Annotated + Field with context.""" + return f"Result for: {query}" + + +@fastmcp_server.tool +def literal_with_context(mode: Literal["fast", "slow"], ctx: Context) -> str: + """Tool using Literal with context.""" + return f"Mode: {mode}" + + class TestFutureAnnotations: async def test_simple_with_context(self): async with Client(fastmcp_server) as client: @@ -102,6 +116,23 @@ class TestFutureAnnotations: "Async request:" in cast(mcp.types.TextContent, result.content[0]).text ) + async def test_annotated_with_context(self): + """Test Annotated[str, Field(...)] works with Context and future annotations.""" + async with Client(fastmcp_server) as client: + result = await client.call_tool( + "annotated_with_context", {"query": "hello"} + ) + assert ( + "Result for: hello" + in cast(mcp.types.TextContent, result.content[0]).text + ) + + async def test_literal_with_context(self): + """Test Literal types work with Context and future annotations.""" + async with Client(fastmcp_server) as client: + result = await client.call_tool("literal_with_context", {"mode": "fast"}) + assert "Mode: fast" in cast(mcp.types.TextContent, result.content[0]).text + async def test_modern_union_syntax_works(self): """Test that modern | union syntax works with future annotations.""" # This demonstrates that our solution works with | syntax when types @@ -138,33 +169,16 @@ class TestFutureAnnotations: ) -@pytest.mark.xfail( - reason="Closure-scoped types cannot be resolved with 'from __future__ import annotations'. " - "When using future annotations, all type annotations become strings that need to be evaluated " - "using eval() in the function's global namespace. Types defined only in closure scope " - "(like local imports or type aliases) are not available in the function's __globals__ " - "and therefore cannot be resolved by get_type_hints()." -) -def test_closure_scoped_types_limitation(): - """ - This test demonstrates that closure-scoped types don't work with future annotations. - - The fundamental issue is that 'from __future__ import annotations' converts all - annotations to strings, and those strings can only be resolved using the function's - global namespace, not local variables from closures. - """ - - def create_failing_closure(): - # This import is only available in the closure scope +def test_closure_scoped_types_with_builtins(): + """Closure-scoped tools work when annotations only reference builtins.""" + def create_closure(): mcp = FastMCP() @mcp.tool def closure_tool(value: str | None) -> str: - """This will fail because Optional can't be resolved from closure import.""" return str(value) return mcp - # This should raise an error during tool registration - create_failing_closure() + create_closure()