mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 23:29:10 +02:00
* Replace vendored DI with uncalled-for FastMCP vendored a minimal DI engine extracted from Docket (~164 lines) with try/except fallback patterns everywhere. The `uncalled-for` package is a clean, typed extraction of this same system, and since Docket will also depend on it (chrisguidry/docket#353), `uncalled_for.Dependency` becomes the single canonical base class. This deletes the `_vendor/docket_di/` directory, replaces all the try/except import patterns with direct `uncalled_for` imports, and updates the `Dependency.execution` → `current_execution` ContextVar references to match the Docket branch. The `Progress` class now delegates to an internal impl and returns `self` from `__aenter__` (matching Docket's pattern) so that ty's generic resolution works without `type: ignore` suppressions. Temporarily points pydocket at the `use-uncalled-for` branch so both sides can be validated together in CI. 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Re-export Dependency from fastmcp.dependencies Internal code like azure.py should import from the fastmcp namespace rather than reaching into uncalled_for directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Import Dependency from fastmcp namespace in tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add generic type parameters to Dependency subclasses Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Mention uncalled-for in DI docs The DI engine now comes from uncalled-for, so the docs should credit it alongside Docket. Also updates the Docket docs link to docket.lol. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Point docket dependency at main Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Bump uncalled-for pin to >=0.2.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix uncalled-for imports for 0.2.0 API changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Support Shared() dependencies without docket Enters a SharedContext at server lifetime so that Shared() dependencies from uncalled-for resolve once and are cached across tool/resource/prompt calls. When running with docket, the Worker already handles this; this covers the non-docket path and direct call_tool() usage. Also re-exports Shared from fastmcp.dependencies. Closes #3251 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Bump docket lockfile to latest main Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove duplicate test classes from rebase conflict resolution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Point docket dependency at pydocket>=0.18.0 release Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Pair SharedContext __aenter__ with __aexit__ in Context lifecycle The old `_ensure_shared_context` on the server called `__aenter__()` on a lazy `SharedContext` but never `__aexit__()`, leaking the exit stack and its resources. Moved the SharedContext management into Context's own enter/exit so it's properly paired: when docket is available the lifespan handles it, otherwise Context creates and cleans up a per-request one. Updated Shared() tests to use Client (which runs the lifespan) rather than calling server methods directly, since cross-request sharing requires a lifespan. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Hoist SharedContext import to module level Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
286 lines
11 KiB
Python
286 lines
11 KiB
Python
"""Advanced tests for dependency injection in FastMCP: context annotations, vendored DI, and auth dependencies."""
|
|
|
|
import inspect
|
|
|
|
import mcp.types as mcp_types
|
|
import pytest
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.client import Client
|
|
from fastmcp.dependencies import CurrentContext
|
|
from fastmcp.server.context import Context
|
|
|
|
|
|
class Connection:
|
|
"""Test connection that tracks whether it's currently open."""
|
|
|
|
def __init__(self):
|
|
self.is_open = False
|
|
|
|
async def __aenter__(self):
|
|
self.is_open = True
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
self.is_open = False
|
|
|
|
|
|
@pytest.fixture
|
|
def mcp():
|
|
"""Create a FastMCP server for testing."""
|
|
return FastMCP("test-server")
|
|
|
|
|
|
class TestTransformContextAnnotations:
|
|
"""Tests for the transform_context_annotations function."""
|
|
|
|
async def test_optional_context_degrades_to_none_without_active_context(self):
|
|
"""Optional Context should resolve to None when no context is active."""
|
|
from fastmcp.server.dependencies import transform_context_annotations
|
|
|
|
async def fn_with_optional_ctx(name: str, ctx: Context | None = None) -> str:
|
|
return name
|
|
|
|
transform_context_annotations(fn_with_optional_ctx)
|
|
sig = inspect.signature(fn_with_optional_ctx)
|
|
ctx_dependency = sig.parameters["ctx"].default
|
|
|
|
resolved_ctx = await ctx_dependency.__aenter__()
|
|
try:
|
|
assert resolved_ctx is None
|
|
finally:
|
|
await ctx_dependency.__aexit__(None, None, None)
|
|
|
|
async def test_optional_context_still_injected_in_foreground_requests(
|
|
self, mcp: FastMCP
|
|
):
|
|
"""Optional Context should still be injected for normal MCP requests."""
|
|
|
|
@mcp.tool()
|
|
async def tool_with_optional_context(
|
|
name: str, ctx: Context | None = None
|
|
) -> str:
|
|
if ctx is None:
|
|
return f"missing:{name}"
|
|
return f"present:{ctx.session_id}:{name}"
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool("tool_with_optional_context", {"name": "x"})
|
|
assert result.content[0].text.startswith("present:")
|
|
|
|
async def test_basic_context_transformation(self, mcp: FastMCP):
|
|
"""Test basic Context type annotation is transformed."""
|
|
|
|
@mcp.tool()
|
|
async def tool_with_context(name: str, ctx: Context) -> str:
|
|
return f"session={ctx.session_id}, name={name}"
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool("tool_with_context", {"name": "test"})
|
|
assert "session=" in result.content[0].text
|
|
assert "name=test" in result.content[0].text
|
|
|
|
async def test_transform_with_var_params(self):
|
|
"""Test transform_context_annotations handles *args and **kwargs correctly."""
|
|
from fastmcp.server.dependencies import transform_context_annotations
|
|
|
|
# This function can't be a tool (FastMCP doesn't support *args/**kwargs),
|
|
# but transform should handle it gracefully for signature inspection
|
|
async def fn_with_var_params(
|
|
first: str, ctx: Context, *args: str, **kwargs: str
|
|
) -> str:
|
|
return f"first={first}"
|
|
|
|
transform_context_annotations(fn_with_var_params)
|
|
sig = inspect.signature(fn_with_var_params)
|
|
|
|
# Verify structure is preserved
|
|
param_kinds = {p.name: p.kind for p in sig.parameters.values()}
|
|
assert param_kinds["first"] == inspect.Parameter.POSITIONAL_OR_KEYWORD
|
|
assert param_kinds["ctx"] == inspect.Parameter.POSITIONAL_OR_KEYWORD
|
|
assert param_kinds["args"] == inspect.Parameter.VAR_POSITIONAL
|
|
assert param_kinds["kwargs"] == inspect.Parameter.VAR_KEYWORD
|
|
|
|
# ctx should now have a default
|
|
assert sig.parameters["ctx"].default is not inspect.Parameter.empty
|
|
|
|
async def test_context_keyword_only(self, mcp: FastMCP):
|
|
"""Test Context transformation preserves keyword-only parameter semantics."""
|
|
from fastmcp.server.dependencies import transform_context_annotations
|
|
|
|
# Define function with keyword-only Context param
|
|
async def fn_with_kw_only(a: str, *, ctx: Context, b: str = "default") -> str:
|
|
return f"a={a}, b={b}"
|
|
|
|
# Transform and check signature structure
|
|
transform_context_annotations(fn_with_kw_only)
|
|
sig = inspect.signature(fn_with_kw_only)
|
|
params = list(sig.parameters.values())
|
|
|
|
# 'a' should be POSITIONAL_OR_KEYWORD
|
|
assert params[0].name == "a"
|
|
assert params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD
|
|
|
|
# 'ctx' should still be KEYWORD_ONLY (after transformation)
|
|
ctx_param = sig.parameters["ctx"]
|
|
assert ctx_param.kind == inspect.Parameter.KEYWORD_ONLY
|
|
|
|
# 'b' should still be KEYWORD_ONLY
|
|
b_param = sig.parameters["b"]
|
|
assert b_param.kind == inspect.Parameter.KEYWORD_ONLY
|
|
|
|
async def test_context_with_annotated(self, mcp: FastMCP):
|
|
"""Test Context with Annotated type is transformed."""
|
|
from typing import Annotated
|
|
|
|
@mcp.tool()
|
|
async def tool_with_annotated_ctx(
|
|
name: str, ctx: Annotated[Context, "custom annotation"]
|
|
) -> str:
|
|
return f"session={ctx.session_id}"
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool("tool_with_annotated_ctx", {"name": "test"})
|
|
assert "session=" in result.content[0].text
|
|
|
|
async def test_context_already_has_dependency_default(self, mcp: FastMCP):
|
|
"""Test that Context with existing Depends default is not re-transformed."""
|
|
|
|
@mcp.tool()
|
|
async def tool_with_explicit_context(
|
|
name: str, ctx: Context = CurrentContext()
|
|
) -> str:
|
|
return f"session={ctx.session_id}"
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool(
|
|
"tool_with_explicit_context", {"name": "test"}
|
|
)
|
|
assert "session=" in result.content[0].text
|
|
|
|
async def test_multiple_context_params(self, mcp: FastMCP):
|
|
"""Test multiple Context-typed parameters are all transformed."""
|
|
|
|
@mcp.tool()
|
|
async def tool_with_multiple_ctx(
|
|
name: str, ctx1: Context, ctx2: Context
|
|
) -> str:
|
|
# Both should refer to same context
|
|
assert ctx1.session_id == ctx2.session_id
|
|
return f"same={ctx1 is ctx2}"
|
|
|
|
# Both ctx params should be excluded from schema
|
|
result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest())
|
|
tool = next(t for t in result.tools if t.name == "tool_with_multiple_ctx")
|
|
assert "name" in tool.inputSchema["properties"]
|
|
assert "ctx1" not in tool.inputSchema["properties"]
|
|
assert "ctx2" not in tool.inputSchema["properties"]
|
|
|
|
async def test_context_in_class_method(self, mcp: FastMCP):
|
|
"""Test Context transformation works with bound methods."""
|
|
|
|
class MyTools:
|
|
def __init__(self, prefix: str):
|
|
self.prefix = prefix
|
|
|
|
async def greet(self, name: str, ctx: Context) -> str:
|
|
return f"{self.prefix} {name}, session={ctx.session_id}"
|
|
|
|
tools = MyTools("Hello")
|
|
mcp.tool()(tools.greet)
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool("greet", {"name": "World"})
|
|
assert "Hello World" in result.content[0].text
|
|
assert "session=" in result.content[0].text
|
|
|
|
async def test_context_in_static_method(self, mcp: FastMCP):
|
|
"""Test Context transformation works with static methods."""
|
|
|
|
class MyTools:
|
|
@staticmethod
|
|
async def static_tool(name: str, ctx: Context) -> str:
|
|
return f"name={name}, session={ctx.session_id}"
|
|
|
|
mcp.tool()(MyTools.static_tool)
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool("static_tool", {"name": "test"})
|
|
assert "name=test" in result.content[0].text
|
|
assert "session=" in result.content[0].text
|
|
|
|
async def test_context_in_callable_class(self, mcp: FastMCP):
|
|
"""Test Context transformation works with callable class instances."""
|
|
from fastmcp.tools import Tool
|
|
|
|
class CallableTool:
|
|
def __init__(self, multiplier: int):
|
|
self.multiplier = multiplier
|
|
|
|
async def __call__(self, x: int, ctx: Context) -> str:
|
|
return f"result={x * self.multiplier}, session={ctx.session_id}"
|
|
|
|
# Use Tool.from_function directly (mcp.tool() decorator doesn't support callable instances)
|
|
tool = Tool.from_function(CallableTool(3))
|
|
mcp.add_tool(tool)
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool("CallableTool", {"x": 5})
|
|
assert "result=15" in result.content[0].text
|
|
assert "session=" in result.content[0].text
|
|
|
|
async def test_context_param_reordering(self, mcp: FastMCP):
|
|
"""Test that Context params are reordered correctly to maintain valid signature."""
|
|
from fastmcp.server.dependencies import transform_context_annotations
|
|
|
|
# Context in middle without default - should be moved after non-default params
|
|
async def fn_with_middle_ctx(a: str, ctx: Context, b: str) -> str:
|
|
return f"{a},{b}"
|
|
|
|
transform_context_annotations(fn_with_middle_ctx)
|
|
sig = inspect.signature(fn_with_middle_ctx)
|
|
params = list(sig.parameters.values())
|
|
|
|
# After transform: a, b should come before ctx (which now has default)
|
|
param_names = [p.name for p in params]
|
|
assert param_names == ["a", "b", "ctx"]
|
|
|
|
# ctx should have a default now
|
|
assert sig.parameters["ctx"].default is not inspect.Parameter.empty
|
|
|
|
async def test_context_resource(self, mcp: FastMCP):
|
|
"""Test Context transformation works with resources."""
|
|
|
|
@mcp.resource("data://test")
|
|
async def resource_with_ctx(ctx: Context) -> str:
|
|
return f"session={ctx.session_id}"
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource("data://test")
|
|
assert len(result) == 1
|
|
assert "session=" in result[0].text
|
|
|
|
async def test_context_resource_template(self, mcp: FastMCP):
|
|
"""Test Context transformation works with resource templates."""
|
|
|
|
@mcp.resource("item://{item_id}")
|
|
async def template_with_ctx(item_id: str, ctx: Context) -> str:
|
|
return f"item={item_id}, session={ctx.session_id}"
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource("item://123")
|
|
assert len(result) == 1
|
|
assert "item=123" in result[0].text
|
|
assert "session=" in result[0].text
|
|
|
|
async def test_context_prompt(self, mcp: FastMCP):
|
|
"""Test Context transformation works with prompts."""
|
|
|
|
@mcp.prompt()
|
|
async def prompt_with_ctx(topic: str, ctx: Context) -> str:
|
|
return f"Write about {topic} (session: {ctx.session_id})"
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.get_prompt("prompt_with_ctx", {"topic": "AI"})
|
|
assert "Write about AI" in result.messages[0].content.text
|
|
assert "session:" in result.messages[0].content.text
|