mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 04:54:17 +02:00
Make pydocket optional and unify DI systems (#2835)
This commit is contained in:
parent
4765f5725a
commit
8e1fd1d700
20 changed files with 1251 additions and 313 deletions
|
|
@ -191,6 +191,10 @@ fastmcp tasks worker server.py
|
|||
Additional workers only work with Redis/Valkey backends. The in-memory backend is single-process only.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
Task-enabled components must be defined at server startup to be registered with all workers. Components added dynamically after the server starts will not be available for background execution.
|
||||
</Warning>
|
||||
|
||||
## Progress Reporting
|
||||
|
||||
The `Progress` dependency lets you report progress back to clients. Inject it as a parameter with a default value, and FastMCP will provide the active progress reporter.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ dependencies = [
|
|||
"mcp>=1.24.0,<2.0",
|
||||
"openapi-pydantic>=0.5.1",
|
||||
"platformdirs>=4.0.0",
|
||||
"pydocket>=0.16.4",
|
||||
"rich>=13.9.4",
|
||||
"cyclopts>=4.0.0",
|
||||
"authlib>=1.6.5",
|
||||
|
|
@ -51,11 +50,12 @@ classifiers = [
|
|||
[project.optional-dependencies]
|
||||
anthropic = ["anthropic>=0.40.0"]
|
||||
openai = ["openai>=1.102.0"]
|
||||
tasks = ["pydocket>=0.16.4"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"dirty-equals>=0.9.0",
|
||||
"fastmcp[anthropic,openai]",
|
||||
"fastmcp[anthropic,openai,tasks]",
|
||||
# add optional dependencies for fastmcp dev
|
||||
"fastapi>=0.115.12",
|
||||
"inline-snapshot[dirty-equals]>=0.27.2",
|
||||
|
|
|
|||
1
src/fastmcp/_vendor/__init__.py
Normal file
1
src/fastmcp/_vendor/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Vendored third-party code for FastMCP."""
|
||||
7
src/fastmcp/_vendor/docket_di/README.md
Normal file
7
src/fastmcp/_vendor/docket_di/README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Vendored Docket DI
|
||||
|
||||
This is a minimal vendored copy of the dependency injection engine from [Docket](https://github.com/chrisguidry/docket), pending its release as a standalone library.
|
||||
|
||||
When `fastmcp[tasks]` is installed, FastMCP uses Docket's DI classes directly for `isinstance` compatibility in worker contexts. This vendored version is only used when Docket is not installed, allowing basic `Depends()` functionality without the full Docket dependency.
|
||||
|
||||
Once the DI component is released separately, this vendored copy will be removed.
|
||||
163
src/fastmcp/_vendor/docket_di/__init__.py
Normal file
163
src/fastmcp/_vendor/docket_di/__init__.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""Vendored dependency injection engine from Docket.
|
||||
|
||||
This is a minimal subset of docket.dependencies for FastMCP's DI system.
|
||||
When docket is installed, FastMCP uses docket's classes directly for
|
||||
isinstance compatibility. This vendored version is only used when docket
|
||||
is not installed.
|
||||
|
||||
Original source: https://github.com/chrisguidry/docket
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import inspect
|
||||
from contextlib import AsyncExitStack
|
||||
from contextvars import ContextVar
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import AbstractAsyncContextManager, AbstractContextManager
|
||||
from typing import (
|
||||
Any,
|
||||
Generic,
|
||||
TypeVar,
|
||||
cast,
|
||||
)
|
||||
|
||||
R = TypeVar("R")
|
||||
|
||||
# Cached signature lookup (simplified from docket.execution.get_signature)
|
||||
_signature_cache: dict[Callable[..., Any], inspect.Signature] = {}
|
||||
|
||||
|
||||
def get_signature(function: Callable[..., Any]) -> inspect.Signature:
|
||||
"""Get cached signature for a function."""
|
||||
if function in _signature_cache:
|
||||
return _signature_cache[function]
|
||||
|
||||
signature_attr = getattr(function, "__signature__", None)
|
||||
if isinstance(signature_attr, inspect.Signature):
|
||||
_signature_cache[function] = signature_attr
|
||||
return signature_attr
|
||||
|
||||
signature = inspect.signature(function)
|
||||
_signature_cache[function] = signature
|
||||
return signature
|
||||
|
||||
|
||||
class Dependency(abc.ABC):
|
||||
"""Base class for all dependencies.
|
||||
|
||||
Subclasses must implement __aenter__ to provide the dependency value.
|
||||
The __aexit__ method is optional for cleanup.
|
||||
"""
|
||||
|
||||
single: bool = False
|
||||
|
||||
@abc.abstractmethod
|
||||
async def __aenter__(self) -> Any: ...
|
||||
|
||||
async def __aexit__(self, *args: object) -> None: # noqa: B027
|
||||
pass
|
||||
|
||||
|
||||
DependencyFunction = Callable[..., Any]
|
||||
|
||||
_parameter_cache: dict[Callable[..., Any], dict[str, Dependency]] = {}
|
||||
|
||||
|
||||
def get_dependency_parameters(
|
||||
function: Callable[..., Any],
|
||||
) -> dict[str, Dependency]:
|
||||
"""Find parameters with Dependency defaults."""
|
||||
if function in _parameter_cache:
|
||||
return _parameter_cache[function]
|
||||
|
||||
dependencies: dict[str, Dependency] = {}
|
||||
signature = get_signature(function)
|
||||
|
||||
for parameter, param in signature.parameters.items():
|
||||
if not isinstance(param.default, Dependency):
|
||||
continue
|
||||
dependencies[parameter] = param.default
|
||||
|
||||
_parameter_cache[function] = dependencies
|
||||
return dependencies
|
||||
|
||||
|
||||
class _Depends(Dependency, Generic[R]):
|
||||
"""Wrapper for user-defined dependency functions."""
|
||||
|
||||
dependency: DependencyFunction
|
||||
|
||||
cache: ContextVar[dict[DependencyFunction, Any]] = ContextVar("cache")
|
||||
stack: ContextVar[AsyncExitStack] = ContextVar("stack")
|
||||
|
||||
def __init__(self, dependency: DependencyFunction) -> None:
|
||||
self.dependency = dependency
|
||||
|
||||
async def _resolve_parameters(self, function: DependencyFunction) -> dict[str, Any]:
|
||||
stack = self.stack.get()
|
||||
arguments: dict[str, Any] = {}
|
||||
parameters = get_dependency_parameters(function)
|
||||
|
||||
for parameter, dependency in parameters.items():
|
||||
arguments[parameter] = await stack.enter_async_context(dependency)
|
||||
|
||||
return arguments
|
||||
|
||||
async def __aenter__(self) -> R:
|
||||
cache = self.cache.get()
|
||||
|
||||
if self.dependency in cache:
|
||||
return cache[self.dependency]
|
||||
|
||||
stack = self.stack.get()
|
||||
arguments = await self._resolve_parameters(self.dependency)
|
||||
|
||||
raw_value = self.dependency(**arguments)
|
||||
|
||||
# Handle different return types
|
||||
resolved_value: R
|
||||
if isinstance(raw_value, AbstractAsyncContextManager):
|
||||
resolved_value = await stack.enter_async_context(raw_value)
|
||||
elif isinstance(raw_value, AbstractContextManager):
|
||||
resolved_value = stack.enter_context(raw_value)
|
||||
elif inspect.iscoroutine(raw_value) or isinstance(raw_value, Awaitable):
|
||||
resolved_value = await cast(Awaitable[R], raw_value)
|
||||
else:
|
||||
resolved_value = cast(R, raw_value)
|
||||
|
||||
cache[self.dependency] = resolved_value
|
||||
return resolved_value
|
||||
|
||||
|
||||
def Depends(dependency: DependencyFunction) -> Any:
|
||||
"""Include a user-defined function as a dependency.
|
||||
|
||||
Dependencies may be:
|
||||
- Synchronous functions returning a value
|
||||
- Asynchronous functions returning a value (awaitable)
|
||||
- Synchronous context managers (using @contextmanager)
|
||||
- Asynchronous context managers (using @asynccontextmanager)
|
||||
|
||||
Example:
|
||||
```python
|
||||
def get_config() -> dict:
|
||||
return {"api_url": "https://api.example.com"}
|
||||
|
||||
@mcp.tool
|
||||
def my_tool(config: dict = Depends(get_config)) -> str:
|
||||
return config["api_url"]
|
||||
```
|
||||
"""
|
||||
return cast(Any, _Depends(dependency))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Dependency",
|
||||
"Depends",
|
||||
"_Depends",
|
||||
"get_dependency_parameters",
|
||||
"get_signature",
|
||||
]
|
||||
|
|
@ -3,9 +3,18 @@
|
|||
This module re-exports dependency injection symbols from Docket and FastMCP
|
||||
to provide a clean, centralized import location for all dependency-related
|
||||
functionality.
|
||||
|
||||
DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket
|
||||
using a vendored DI engine. Only task-related dependencies (CurrentDocket,
|
||||
CurrentWorker) and background task execution require fastmcp[tasks].
|
||||
"""
|
||||
|
||||
from docket import Depends
|
||||
# Try docket first for isinstance compatibility, fall back to vendored
|
||||
try:
|
||||
from docket import Depends
|
||||
except ImportError:
|
||||
from fastmcp._vendor.docket_di import Depends
|
||||
|
||||
|
||||
from fastmcp.server.dependencies import (
|
||||
CurrentContext,
|
||||
|
|
@ -13,6 +22,7 @@ from fastmcp.server.dependencies import (
|
|||
CurrentFastMCP,
|
||||
CurrentWorker,
|
||||
Progress,
|
||||
ProgressLike,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -22,4 +32,5 @@ __all__ = [
|
|||
"CurrentWorker",
|
||||
"Depends",
|
||||
"Progress",
|
||||
"ProgressLike",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ from mcp.types import PromptArgument as SDKPromptArgument
|
|||
from pydantic import Field
|
||||
|
||||
from fastmcp.exceptions import PromptError
|
||||
from fastmcp.server.dependencies import without_injected_parameters
|
||||
from fastmcp.server.dependencies import (
|
||||
transform_context_annotations,
|
||||
without_injected_parameters,
|
||||
)
|
||||
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.json_schema import compress_schema
|
||||
|
|
@ -442,6 +445,9 @@ class FunctionPrompt(Prompt):
|
|||
if isinstance(fn, staticmethod):
|
||||
fn = fn.__func__ # type: ignore[assignment]
|
||||
|
||||
# Transform Context type annotations to Depends() for unified DI
|
||||
fn = transform_context_annotations(fn)
|
||||
|
||||
# Wrap fn to handle dependency resolution internally
|
||||
wrapped_fn = without_injected_parameters(fn)
|
||||
type_adapter = get_cached_typeadapter(wrapped_fn)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ from pydantic import (
|
|||
)
|
||||
from typing_extensions import Self
|
||||
|
||||
from fastmcp.server.dependencies import without_injected_parameters
|
||||
from fastmcp.server.dependencies import (
|
||||
transform_context_annotations,
|
||||
without_injected_parameters,
|
||||
)
|
||||
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.types import get_fn_name
|
||||
|
|
@ -444,6 +447,9 @@ class FunctionResource(Resource):
|
|||
task_config = task
|
||||
task_config.validate_function(fn, func_name)
|
||||
|
||||
# Transform Context type annotations to Depends() for unified DI
|
||||
fn = transform_context_annotations(fn)
|
||||
|
||||
# Wrap fn to handle dependency resolution internally
|
||||
wrapped_fn = without_injected_parameters(fn)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ from pydantic import (
|
|||
)
|
||||
|
||||
from fastmcp.resources.resource import Resource, ResourceResult
|
||||
from fastmcp.server.dependencies import without_injected_parameters
|
||||
from fastmcp.server.dependencies import (
|
||||
transform_context_annotations,
|
||||
without_injected_parameters,
|
||||
)
|
||||
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.json_schema import compress_schema
|
||||
|
|
@ -535,6 +538,9 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|||
if isinstance(fn, staticmethod):
|
||||
fn = fn.__func__
|
||||
|
||||
# Transform Context type annotations to Depends() for unified DI
|
||||
fn = transform_context_annotations(fn)
|
||||
|
||||
wrapper_fn = without_injected_parameters(fn)
|
||||
type_adapter = get_cached_typeadapter(wrapper_fn)
|
||||
parameters = type_adapter.json_schema()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
"""Dependency injection for FastMCP.
|
||||
|
||||
DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket
|
||||
using a vendored DI engine. Only task-related dependencies (CurrentDocket,
|
||||
CurrentWorker) and background task execution require fastmcp[tasks].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
|
|
@ -7,10 +14,8 @@ from collections.abc import AsyncGenerator, Callable
|
|||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING, Any, cast, get_type_hints
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast, get_type_hints, runtime_checkable
|
||||
|
||||
from docket.dependencies import Dependency, _Depends, get_dependency_parameters
|
||||
from docket.dependencies import Progress as DocketProgress
|
||||
from mcp.server.auth.middleware.auth_context import (
|
||||
get_access_token as _sdk_get_access_token,
|
||||
)
|
||||
|
|
@ -24,7 +29,7 @@ from starlette.requests import Request
|
|||
from fastmcp.exceptions import FastMCPError
|
||||
from fastmcp.server.auth import AccessToken
|
||||
from fastmcp.server.http import _current_http_request
|
||||
from fastmcp.utilities.types import is_class_member_of_type
|
||||
from fastmcp.utilities.types import find_kwarg_by_type, is_class_member_of_type
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
|
|
@ -33,12 +38,6 @@ if TYPE_CHECKING:
|
|||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
# ContextVars for tracking Docket infrastructure
|
||||
_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None) # type: ignore[assignment]
|
||||
_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None) # type: ignore[assignment]
|
||||
_current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar( # type: ignore[invalid-assignment]
|
||||
"server", default=None
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AccessToken",
|
||||
|
|
@ -52,34 +51,382 @@ __all__ = [
|
|||
"get_http_headers",
|
||||
"get_http_request",
|
||||
"get_server",
|
||||
"is_docket_available",
|
||||
"require_docket",
|
||||
"resolve_dependencies",
|
||||
"transform_context_annotations",
|
||||
"without_injected_parameters",
|
||||
]
|
||||
|
||||
|
||||
def _find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None:
|
||||
"""Find the name of the kwarg that is of type kwarg_type.
|
||||
# --- ContextVars ---
|
||||
|
||||
This is the legacy dependency injection approach, used specifically for
|
||||
injecting the Context object when a function parameter is typed as Context.
|
||||
_current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar(
|
||||
"server", default=None
|
||||
)
|
||||
_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None)
|
||||
_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None)
|
||||
|
||||
Includes union types that contain the kwarg_type, as well as Annotated types.
|
||||
|
||||
# --- Docket availability check ---
|
||||
|
||||
_DOCKET_AVAILABLE: bool | None = None
|
||||
|
||||
|
||||
def is_docket_available() -> bool:
|
||||
"""Check if pydocket is installed."""
|
||||
global _DOCKET_AVAILABLE
|
||||
if _DOCKET_AVAILABLE is None:
|
||||
try:
|
||||
import docket # noqa: F401
|
||||
|
||||
_DOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DOCKET_AVAILABLE = False
|
||||
return _DOCKET_AVAILABLE
|
||||
|
||||
|
||||
def require_docket(feature: str) -> None:
|
||||
"""Raise ImportError with install instructions if docket not available.
|
||||
|
||||
Args:
|
||||
feature: Description of what requires docket (e.g., "`task=True`",
|
||||
"CurrentDocket()"). Will be included in the error message.
|
||||
"""
|
||||
if not is_docket_available():
|
||||
raise ImportError(
|
||||
f"FastMCP background tasks require the `tasks` extra. "
|
||||
f"Install with: pip install 'fastmcp[tasks]'. "
|
||||
f"(Triggered by {feature})"
|
||||
)
|
||||
|
||||
if inspect.ismethod(fn) and hasattr(fn, "__func__"):
|
||||
fn = fn.__func__
|
||||
|
||||
# --- Dependency injection imports ---
|
||||
# Try docket first for isinstance compatibility in worker context,
|
||||
# fall back to vendored DI engine when docket is not installed.
|
||||
|
||||
try:
|
||||
from docket.dependencies import (
|
||||
Dependency,
|
||||
_Depends,
|
||||
get_dependency_parameters,
|
||||
)
|
||||
except ImportError:
|
||||
from fastmcp._vendor.docket_di import (
|
||||
Dependency,
|
||||
_Depends,
|
||||
get_dependency_parameters,
|
||||
)
|
||||
|
||||
# Import Progress separately to avoid breaking DI fallback if Progress is missing
|
||||
try:
|
||||
from docket.dependencies import Progress as DocketProgress
|
||||
except ImportError:
|
||||
DocketProgress = None # type: ignore[assignment]
|
||||
|
||||
|
||||
# --- Context utilities ---
|
||||
|
||||
|
||||
def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Transform ctx: Context into ctx: Context = CurrentContext().
|
||||
|
||||
Transforms ALL params typed as Context to use Docket's DI system,
|
||||
unless they already have a Dependency-based default (like CurrentContext()).
|
||||
|
||||
This unifies the legacy type annotation DI with Docket's Depends() system,
|
||||
allowing both patterns to work through a single resolution path.
|
||||
|
||||
Note: Only POSITIONAL_OR_KEYWORD parameters are reordered (params with defaults
|
||||
after those without). KEYWORD_ONLY parameters keep their position since Python
|
||||
allows them to have defaults in any order.
|
||||
|
||||
Args:
|
||||
fn: Function to transform
|
||||
|
||||
Returns:
|
||||
Function with modified signature (same function object, updated __signature__)
|
||||
"""
|
||||
from fastmcp.server.context import Context
|
||||
|
||||
# Get the function's signature
|
||||
try:
|
||||
sig = inspect.signature(fn)
|
||||
except (ValueError, TypeError):
|
||||
return fn
|
||||
|
||||
# Get type hints for accurate type checking
|
||||
try:
|
||||
type_hints = get_type_hints(fn, include_extras=True)
|
||||
except Exception:
|
||||
type_hints = getattr(fn, "__annotations__", {})
|
||||
|
||||
sig = inspect.signature(fn)
|
||||
# First pass: identify which params need transformation
|
||||
params_to_transform: set[str] = set()
|
||||
for name, param in sig.parameters.items():
|
||||
annotation = type_hints.get(name, param.annotation)
|
||||
if is_class_member_of_type(annotation, kwarg_type):
|
||||
return name
|
||||
return None
|
||||
if is_class_member_of_type(annotation, Context):
|
||||
if not isinstance(param.default, Dependency):
|
||||
params_to_transform.add(name)
|
||||
|
||||
if not params_to_transform:
|
||||
return fn
|
||||
|
||||
# Second pass: build new param list preserving parameter kind structure
|
||||
# Python signature structure: [POSITIONAL_ONLY] / [POSITIONAL_OR_KEYWORD] *args [KEYWORD_ONLY] **kwargs
|
||||
# Within POSITIONAL_ONLY and POSITIONAL_OR_KEYWORD: params without defaults must come first
|
||||
# KEYWORD_ONLY params can have defaults in any order
|
||||
P = inspect.Parameter
|
||||
|
||||
# Group params by section, preserving order within each
|
||||
positional_only_no_default: list[P] = []
|
||||
positional_only_with_default: list[P] = []
|
||||
positional_or_keyword_no_default: list[P] = []
|
||||
positional_or_keyword_with_default: list[P] = []
|
||||
var_positional: list[P] = [] # *args (at most one)
|
||||
keyword_only: list[P] = [] # After * or *args, order preserved
|
||||
var_keyword: list[P] = [] # **kwargs (at most one)
|
||||
|
||||
for name, param in sig.parameters.items():
|
||||
# Transform Context params by adding CurrentContext default
|
||||
if name in params_to_transform:
|
||||
# We use CurrentContext() instead of Depends(get_context) because
|
||||
# get_context() returns the Context which is an AsyncContextManager,
|
||||
# and the DI system would try to enter it again (it's already entered)
|
||||
param = param.replace(default=CurrentContext())
|
||||
|
||||
# Sort into buckets based on parameter kind
|
||||
if param.kind == P.POSITIONAL_ONLY:
|
||||
if param.default is P.empty:
|
||||
positional_only_no_default.append(param)
|
||||
else:
|
||||
positional_only_with_default.append(param)
|
||||
elif param.kind == P.POSITIONAL_OR_KEYWORD:
|
||||
if param.default is P.empty:
|
||||
positional_or_keyword_no_default.append(param)
|
||||
else:
|
||||
positional_or_keyword_with_default.append(param)
|
||||
elif param.kind == P.VAR_POSITIONAL:
|
||||
var_positional.append(param)
|
||||
elif param.kind == P.KEYWORD_ONLY:
|
||||
keyword_only.append(param)
|
||||
elif param.kind == P.VAR_KEYWORD:
|
||||
var_keyword.append(param)
|
||||
|
||||
# Reconstruct parameter list maintaining Python's required structure
|
||||
new_params: list[P] = (
|
||||
positional_only_no_default
|
||||
+ positional_only_with_default
|
||||
+ positional_or_keyword_no_default
|
||||
+ positional_or_keyword_with_default
|
||||
+ var_positional
|
||||
+ keyword_only
|
||||
+ var_keyword
|
||||
)
|
||||
|
||||
# Update function's signature in place
|
||||
# Handle methods by setting signature on the underlying function
|
||||
# For bound methods, we need to preserve the 'self' parameter because
|
||||
# inspect.signature(bound_method) automatically removes the first param
|
||||
if inspect.ismethod(fn):
|
||||
# Get the original __func__ signature which includes 'self'
|
||||
func_sig = inspect.signature(fn.__func__) # type: ignore[union-attr]
|
||||
# Insert 'self' at the beginning of our new params
|
||||
self_param = next(iter(func_sig.parameters.values())) # Should be 'self'
|
||||
new_sig = func_sig.replace(parameters=[self_param, *new_params])
|
||||
fn.__func__.__signature__ = new_sig # type: ignore[union-attr]
|
||||
else:
|
||||
new_sig = sig.replace(parameters=new_params)
|
||||
fn.__signature__ = new_sig # type: ignore[attr-defined]
|
||||
|
||||
# Clear caches that may have cached the old signature
|
||||
# This ensures get_dependency_parameters and without_injected_parameters
|
||||
# see the transformed signature
|
||||
_clear_signature_caches(fn)
|
||||
|
||||
return fn
|
||||
|
||||
|
||||
def _clear_signature_caches(fn: Callable[..., Any]) -> None:
|
||||
"""Clear signature-related caches for a function.
|
||||
|
||||
Called after modifying a function's signature to ensure downstream
|
||||
code sees the updated signature.
|
||||
"""
|
||||
# Clear vendored DI caches
|
||||
from fastmcp._vendor.docket_di import _parameter_cache, _signature_cache
|
||||
|
||||
_signature_cache.pop(fn, None)
|
||||
_parameter_cache.pop(fn, None)
|
||||
|
||||
# Also clear for __func__ if it's a method
|
||||
if inspect.ismethod(fn):
|
||||
_signature_cache.pop(fn.__func__, None) # type: ignore[union-attr]
|
||||
_parameter_cache.pop(fn.__func__, None) # type: ignore[union-attr]
|
||||
|
||||
# Try to clear docket caches if docket is installed
|
||||
if is_docket_available():
|
||||
try:
|
||||
from docket.dependencies import _parameter_cache as docket_param_cache
|
||||
from docket.execution import _signature_cache as docket_sig_cache
|
||||
|
||||
docket_sig_cache.pop(fn, None)
|
||||
docket_param_cache.pop(fn, None)
|
||||
if inspect.ismethod(fn):
|
||||
docket_sig_cache.pop(fn.__func__, None) # type: ignore[union-attr]
|
||||
docket_param_cache.pop(fn.__func__, None) # type: ignore[union-attr]
|
||||
except (ImportError, AttributeError):
|
||||
pass # Cache access not available in this docket version
|
||||
|
||||
|
||||
def get_context() -> Context:
|
||||
"""Get the current FastMCP Context instance directly."""
|
||||
from fastmcp.server.context import _current_context
|
||||
|
||||
context = _current_context.get()
|
||||
if context is None:
|
||||
raise RuntimeError("No active context found.")
|
||||
return context
|
||||
|
||||
|
||||
def get_server() -> FastMCP:
|
||||
"""Get the current FastMCP server instance directly.
|
||||
|
||||
Returns:
|
||||
The active FastMCP server
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no server in context
|
||||
"""
|
||||
server_ref = _current_server.get()
|
||||
if server_ref is None:
|
||||
raise RuntimeError("No FastMCP server instance in context")
|
||||
server = server_ref()
|
||||
if server is None:
|
||||
raise RuntimeError("FastMCP server instance is no longer available")
|
||||
return server
|
||||
|
||||
|
||||
def get_http_request() -> Request:
|
||||
"""Get the current HTTP request.
|
||||
|
||||
Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context.
|
||||
"""
|
||||
# Try MCP SDK's request_ctx first (set during normal MCP request handling)
|
||||
request = None
|
||||
with contextlib.suppress(LookupError):
|
||||
request = request_ctx.get().request
|
||||
|
||||
# Fallback to FastMCP's HTTP context variable
|
||||
# This is needed during `on_initialize` middleware where request_ctx isn't set yet
|
||||
if request is None:
|
||||
request = _current_http_request.get()
|
||||
|
||||
if request is None:
|
||||
raise RuntimeError("No active HTTP request found.")
|
||||
return request
|
||||
|
||||
|
||||
def get_http_headers(include_all: bool = False) -> dict[str, str]:
|
||||
"""Extract headers from the current HTTP request if available.
|
||||
|
||||
Never raises an exception, even if there is no active HTTP request (in which case
|
||||
an empty dict is returned).
|
||||
|
||||
By default, strips problematic headers like `content-length` that cause issues
|
||||
if forwarded to downstream clients. If `include_all` is True, all headers are returned.
|
||||
"""
|
||||
if include_all:
|
||||
exclude_headers: set[str] = set()
|
||||
else:
|
||||
exclude_headers = {
|
||||
"host",
|
||||
"content-length",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"te",
|
||||
"keep-alive",
|
||||
"expect",
|
||||
"accept",
|
||||
# Proxy-related headers
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
# MCP-related headers
|
||||
"mcp-session-id",
|
||||
}
|
||||
# (just in case)
|
||||
if not all(h.lower() == h for h in exclude_headers):
|
||||
raise ValueError("Excluded headers must be lowercase")
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
request = get_http_request()
|
||||
for name, value in request.headers.items():
|
||||
lower_name = name.lower()
|
||||
if lower_name not in exclude_headers:
|
||||
headers[lower_name] = str(value)
|
||||
return headers
|
||||
except RuntimeError:
|
||||
return {}
|
||||
|
||||
|
||||
def get_access_token() -> AccessToken | None:
|
||||
"""Get the FastMCP access token from the current context.
|
||||
|
||||
This function first tries to get the token from the current HTTP request's scope,
|
||||
which is more reliable for long-lived connections where the SDK's auth_context_var
|
||||
may become stale after token refresh. Falls back to the SDK's context var if no
|
||||
request is available.
|
||||
|
||||
Returns:
|
||||
The access token if an authenticated user is available, None otherwise.
|
||||
"""
|
||||
access_token: _SDKAccessToken | None = None
|
||||
|
||||
# First, try to get from current HTTP request's scope (issue #1863)
|
||||
# This is more reliable than auth_context_var for Streamable HTTP sessions
|
||||
# where tokens may be refreshed between MCP messages
|
||||
try:
|
||||
request = get_http_request()
|
||||
user = request.scope.get("user")
|
||||
if isinstance(user, AuthenticatedUser):
|
||||
access_token = user.access_token
|
||||
except RuntimeError:
|
||||
# No HTTP request available, fall back to context var
|
||||
pass
|
||||
|
||||
# Fall back to SDK's context var if we didn't get a token from the request
|
||||
if access_token is None:
|
||||
access_token = _sdk_get_access_token()
|
||||
|
||||
if access_token is None or isinstance(access_token, AccessToken):
|
||||
return access_token
|
||||
|
||||
# If the object is not a FastMCP AccessToken, convert it to one if the
|
||||
# fields are compatible (e.g. `claims` is not present in the SDK's AccessToken).
|
||||
# This is a workaround for the case where the SDK or auth provider returns a different type
|
||||
# If it fails, it will raise a TypeError
|
||||
try:
|
||||
access_token_as_dict = access_token.model_dump()
|
||||
return AccessToken(
|
||||
token=access_token_as_dict["token"],
|
||||
client_id=access_token_as_dict["client_id"],
|
||||
scopes=access_token_as_dict["scopes"],
|
||||
# Optional fields
|
||||
expires_at=access_token_as_dict.get("expires_at"),
|
||||
resource=access_token_as_dict.get("resource"),
|
||||
claims=access_token_as_dict.get("claims"),
|
||||
)
|
||||
except Exception as e:
|
||||
raise TypeError(
|
||||
f"Expected fastmcp.server.auth.auth.AccessToken, got {type(access_token).__name__}. "
|
||||
"Ensure the SDK is using the correct AccessToken type."
|
||||
) from e
|
||||
|
||||
|
||||
# --- Schema generation helper ---
|
||||
|
||||
|
||||
@lru_cache(maxsize=5000)
|
||||
|
|
@ -91,6 +438,10 @@ def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|||
validation. The wrapper internally handles all dependency resolution and
|
||||
Context injection when called.
|
||||
|
||||
Handles:
|
||||
- Legacy Context injection (always works)
|
||||
- Depends() injection (always works - uses docket or vendored DI engine)
|
||||
|
||||
Args:
|
||||
fn: Original function with Context and/or dependencies
|
||||
|
||||
|
|
@ -100,7 +451,7 @@ def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|||
from fastmcp.server.context import Context
|
||||
|
||||
# Identify parameters to exclude
|
||||
context_kwarg = _find_kwarg_by_type(fn, Context)
|
||||
context_kwarg = find_kwarg_by_type(fn, Context)
|
||||
dependency_params = get_dependency_parameters(fn)
|
||||
|
||||
exclude = set()
|
||||
|
|
@ -128,7 +479,7 @@ def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|||
return result
|
||||
|
||||
# Set wrapper metadata (only parameter annotations, not return type)
|
||||
wrapper.__signature__ = new_sig # type: ignore
|
||||
wrapper.__signature__ = new_sig # type: ignore[attr-defined]
|
||||
wrapper.__annotations__ = {
|
||||
k: v
|
||||
for k, v in getattr(fn, "__annotations__", {}).items()
|
||||
|
|
@ -140,6 +491,9 @@ def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|||
return wrapper
|
||||
|
||||
|
||||
# --- Dependency resolution ---
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _resolve_fastmcp_dependencies(
|
||||
fn: Callable[..., Any], arguments: dict[str, Any]
|
||||
|
|
@ -213,24 +567,26 @@ async def _resolve_fastmcp_dependencies(
|
|||
async def resolve_dependencies(
|
||||
fn: Callable[..., Any], arguments: dict[str, Any]
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
"""Resolve dependencies and inject Context for a FastMCP function.
|
||||
"""Resolve dependencies for a FastMCP function.
|
||||
|
||||
This function:
|
||||
1. Filters out any dependency parameter names from user arguments (security)
|
||||
2. Resolves Docket dependencies
|
||||
3. Injects Context if needed
|
||||
4. Merges everything together
|
||||
2. Resolves Depends() parameters via the DI system
|
||||
|
||||
The filtering prevents external callers from overriding injected parameters by
|
||||
providing values for dependency parameter names. This is a security feature.
|
||||
|
||||
Note: Context injection is handled via transform_context_annotations() which
|
||||
converts `ctx: Context` to `ctx: Context = Depends(get_context)` at registration
|
||||
time, so all injection goes through the unified DI system.
|
||||
|
||||
Args:
|
||||
fn: The function to resolve dependencies for
|
||||
arguments: User arguments (may contain keys that match dependency names,
|
||||
which will be filtered out)
|
||||
|
||||
Yields:
|
||||
Dictionary of filtered user args + resolved dependencies + Context
|
||||
Dictionary of filtered user args + resolved dependencies
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -240,8 +596,6 @@ async def resolve_dependencies(
|
|||
result = await result
|
||||
```
|
||||
"""
|
||||
from fastmcp.server.context import Context
|
||||
|
||||
# Filter out dependency parameters from user arguments to prevent override
|
||||
# This is a security measure - external callers should never be able to
|
||||
# provide values for injected parameters
|
||||
|
|
@ -249,29 +603,23 @@ async def resolve_dependencies(
|
|||
user_args = {k: v for k, v in arguments.items() if k not in dependency_params}
|
||||
|
||||
async with _resolve_fastmcp_dependencies(fn, user_args) as resolved_kwargs:
|
||||
# Inject Context if needed
|
||||
context_kwarg = _find_kwarg_by_type(fn, kwarg_type=Context)
|
||||
if context_kwarg and context_kwarg not in resolved_kwargs:
|
||||
resolved_kwargs[context_kwarg] = get_context()
|
||||
|
||||
yield resolved_kwargs
|
||||
|
||||
|
||||
def get_context() -> Context:
|
||||
from fastmcp.server.context import _current_context
|
||||
|
||||
context = _current_context.get()
|
||||
if context is None:
|
||||
raise RuntimeError("No active context found.")
|
||||
return context
|
||||
# --- Dependency classes ---
|
||||
# These must inherit from docket.dependencies.Dependency when docket is available
|
||||
# so that get_dependency_parameters can detect them.
|
||||
|
||||
|
||||
class _CurrentContext(Dependency):
|
||||
"""Internal dependency class for CurrentContext."""
|
||||
class _CurrentContext(Dependency): # type: ignore[misc]
|
||||
"""Async context manager for Context dependency."""
|
||||
|
||||
async def __aenter__(self) -> Context:
|
||||
return get_context()
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def CurrentContext() -> Context:
|
||||
"""Get the current FastMCP Context instance.
|
||||
|
|
@ -298,20 +646,23 @@ def CurrentContext() -> Context:
|
|||
return cast("Context", _CurrentContext())
|
||||
|
||||
|
||||
class _CurrentDocket(Dependency):
|
||||
"""Internal dependency class for CurrentDocket."""
|
||||
class _CurrentDocket(Dependency): # type: ignore[misc]
|
||||
"""Async context manager for Docket dependency."""
|
||||
|
||||
async def __aenter__(self) -> Docket:
|
||||
# Get Docket from ContextVar (set by _docket_lifespan)
|
||||
require_docket("CurrentDocket()")
|
||||
docket = _current_docket.get()
|
||||
if docket is None:
|
||||
raise RuntimeError(
|
||||
"No Docket instance found. Docket is only available within "
|
||||
"a running FastMCP server context."
|
||||
"No Docket instance found. Docket is only initialized when there are "
|
||||
"task-enabled components (task=True). Add task=True to a component "
|
||||
"to enable Docket infrastructure."
|
||||
)
|
||||
|
||||
return docket
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def CurrentDocket() -> Docket:
|
||||
"""Get the current Docket instance managed by FastMCP.
|
||||
|
|
@ -324,6 +675,7 @@ def CurrentDocket() -> Docket:
|
|||
|
||||
Raises:
|
||||
RuntimeError: If not within a FastMCP server context
|
||||
ImportError: If fastmcp[tasks] not installed
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -335,22 +687,27 @@ def CurrentDocket() -> Docket:
|
|||
return "Scheduled"
|
||||
```
|
||||
"""
|
||||
require_docket("CurrentDocket()")
|
||||
return cast("Docket", _CurrentDocket())
|
||||
|
||||
|
||||
class _CurrentWorker(Dependency):
|
||||
"""Internal dependency class for CurrentWorker."""
|
||||
class _CurrentWorker(Dependency): # type: ignore[misc]
|
||||
"""Async context manager for Worker dependency."""
|
||||
|
||||
async def __aenter__(self) -> Worker:
|
||||
require_docket("CurrentWorker()")
|
||||
worker = _current_worker.get()
|
||||
if worker is None:
|
||||
raise RuntimeError(
|
||||
"No Worker instance found. Worker is only available within "
|
||||
"a running FastMCP server context."
|
||||
"No Worker instance found. Worker is only initialized when there are "
|
||||
"task-enabled components (task=True). Add task=True to a component "
|
||||
"to enable Docket infrastructure."
|
||||
)
|
||||
|
||||
return worker
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def CurrentWorker() -> Worker:
|
||||
"""Get the current Docket Worker instance managed by FastMCP.
|
||||
|
|
@ -363,6 +720,7 @@ def CurrentWorker() -> Worker:
|
|||
|
||||
Raises:
|
||||
RuntimeError: If not within a FastMCP server context
|
||||
ImportError: If fastmcp[tasks] not installed
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -373,26 +731,109 @@ def CurrentWorker() -> Worker:
|
|||
return f"Worker: {worker.name}"
|
||||
```
|
||||
"""
|
||||
require_docket("CurrentWorker()")
|
||||
return cast("Worker", _CurrentWorker())
|
||||
|
||||
|
||||
class InMemoryProgress(DocketProgress):
|
||||
class _CurrentFastMCP(Dependency): # type: ignore[misc]
|
||||
"""Async context manager for FastMCP server dependency."""
|
||||
|
||||
async def __aenter__(self) -> FastMCP:
|
||||
server_ref = _current_server.get()
|
||||
if server_ref is None:
|
||||
raise RuntimeError("No FastMCP server instance in context")
|
||||
server = server_ref()
|
||||
if server is None:
|
||||
raise RuntimeError("FastMCP server instance is no longer available")
|
||||
return server
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def CurrentFastMCP() -> FastMCP:
|
||||
"""Get the current FastMCP server instance.
|
||||
|
||||
This dependency provides access to the active FastMCP server.
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to the active FastMCP server
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no server in context (during resolution)
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.dependencies import CurrentFastMCP
|
||||
|
||||
@mcp.tool()
|
||||
async def introspect(server: FastMCP = CurrentFastMCP()) -> str:
|
||||
return f"Server: {server.name}"
|
||||
```
|
||||
"""
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
return cast(FastMCP, _CurrentFastMCP())
|
||||
|
||||
|
||||
# --- Progress dependency ---
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ProgressLike(Protocol):
|
||||
"""Protocol for progress tracking interface.
|
||||
|
||||
Defines the common interface between InMemoryProgress (server context)
|
||||
and Docket's Progress (worker context).
|
||||
"""
|
||||
|
||||
@property
|
||||
def current(self) -> int | None:
|
||||
"""Current progress value."""
|
||||
...
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total/target progress value."""
|
||||
...
|
||||
|
||||
@property
|
||||
def message(self) -> str | None:
|
||||
"""Current progress message."""
|
||||
...
|
||||
|
||||
async def set_total(self, total: int) -> None:
|
||||
"""Set the total/target value for progress tracking."""
|
||||
...
|
||||
|
||||
async def increment(self, amount: int = 1) -> None:
|
||||
"""Atomically increment the current progress value."""
|
||||
...
|
||||
|
||||
async def set_message(self, message: str | None) -> None:
|
||||
"""Update the progress status message."""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryProgress:
|
||||
"""In-memory progress tracker for immediate tool execution.
|
||||
|
||||
Provides the same interface as Progress but stores state in memory
|
||||
Provides the same interface as Docket's Progress but stores state in memory
|
||||
instead of Redis. Useful for testing and immediate execution where
|
||||
progress doesn't need to be observable across processes.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._current: int | None = None
|
||||
self._total: int = 1
|
||||
self._message: str | None = None
|
||||
|
||||
async def __aenter__(self) -> DocketProgress:
|
||||
async def __aenter__(self) -> InMemoryProgress:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def current(self) -> int | None:
|
||||
return self._current
|
||||
|
|
@ -425,202 +866,43 @@ class InMemoryProgress(DocketProgress):
|
|||
self._message = message
|
||||
|
||||
|
||||
class Progress(DocketProgress):
|
||||
class Progress(Dependency): # type: ignore[misc]
|
||||
"""FastMCP Progress dependency that works in both server and worker contexts.
|
||||
|
||||
Extends Docket's Progress to handle two execution modes:
|
||||
- In Docket worker: Uses the execution's progress (standard Docket behavior)
|
||||
- In FastMCP server: Uses in-memory progress (not observable remotely)
|
||||
Handles three execution modes:
|
||||
- In Docket worker: Uses the execution's progress (observable via Redis)
|
||||
- In FastMCP server with Docket: Falls back to in-memory progress
|
||||
- In FastMCP server without Docket: Uses in-memory progress
|
||||
|
||||
This allows tools to use Progress() regardless of whether they're called
|
||||
immediately or as background tasks.
|
||||
immediately or as background tasks, and regardless of whether pydocket
|
||||
is installed.
|
||||
"""
|
||||
|
||||
async def __aenter__(self) -> DocketProgress:
|
||||
# Try to get execution from Docket worker context
|
||||
try:
|
||||
return await super().__aenter__()
|
||||
except LookupError:
|
||||
# Not in worker context - return in-memory progress
|
||||
docket = _current_docket.get()
|
||||
if docket is None:
|
||||
raise RuntimeError(
|
||||
"Progress dependency requires a FastMCP server context."
|
||||
) from None
|
||||
|
||||
# Return in-memory progress for immediate execution
|
||||
return InMemoryProgress()
|
||||
|
||||
|
||||
class _CurrentFastMCP(Dependency):
|
||||
"""Internal dependency class for CurrentFastMCP."""
|
||||
|
||||
async def __aenter__(self):
|
||||
async def __aenter__(self) -> ProgressLike:
|
||||
# Check if we're in a FastMCP server context
|
||||
server_ref = _current_server.get()
|
||||
if server_ref is None:
|
||||
raise RuntimeError("No FastMCP server instance in context")
|
||||
server = server_ref()
|
||||
if server is None:
|
||||
raise RuntimeError("FastMCP server instance is no longer available")
|
||||
return server
|
||||
if server_ref is None or server_ref() is None:
|
||||
raise RuntimeError("Progress dependency requires a FastMCP server context.")
|
||||
|
||||
# If pydocket is installed, try to use Docket's progress
|
||||
if is_docket_available():
|
||||
from docket.dependencies import Progress as DocketProgress
|
||||
|
||||
def CurrentFastMCP():
|
||||
"""Get the current FastMCP server instance.
|
||||
# Try to get execution from Docket worker context
|
||||
try:
|
||||
docket_progress = DocketProgress()
|
||||
return await docket_progress.__aenter__()
|
||||
except LookupError:
|
||||
# Not in worker context - fall through to in-memory progress
|
||||
pass
|
||||
|
||||
This dependency provides access to the active FastMCP server.
|
||||
# Return in-memory progress for immediate execution
|
||||
# This is used when:
|
||||
# 1. pydocket is not installed
|
||||
# 2. Docket is not running (no task-enabled components)
|
||||
# 3. In server context (not worker context)
|
||||
return InMemoryProgress()
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to the active FastMCP server
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no server in context (during resolution)
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.dependencies import CurrentFastMCP
|
||||
|
||||
@mcp.tool()
|
||||
async def introspect(server: FastMCP = CurrentFastMCP()) -> str:
|
||||
return f"Server: {server.name}"
|
||||
```
|
||||
"""
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
return cast(FastMCP, _CurrentFastMCP())
|
||||
|
||||
|
||||
def get_server():
|
||||
"""Get the current FastMCP server instance directly.
|
||||
|
||||
Returns:
|
||||
The active FastMCP server
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no server in context
|
||||
"""
|
||||
|
||||
server_ref = _current_server.get()
|
||||
if server_ref is None:
|
||||
raise RuntimeError("No FastMCP server instance in context")
|
||||
server = server_ref()
|
||||
if server is None:
|
||||
raise RuntimeError("FastMCP server instance is no longer available")
|
||||
return server
|
||||
|
||||
|
||||
def get_http_request() -> Request:
|
||||
# Try MCP SDK's request_ctx first (set during normal MCP request handling)
|
||||
request = None
|
||||
with contextlib.suppress(LookupError):
|
||||
request = request_ctx.get().request
|
||||
|
||||
# Fallback to FastMCP's HTTP context variable
|
||||
# This is needed during `on_initialize` middleware where request_ctx isn't set yet
|
||||
if request is None:
|
||||
request = _current_http_request.get()
|
||||
|
||||
if request is None:
|
||||
raise RuntimeError("No active HTTP request found.")
|
||||
return request
|
||||
|
||||
|
||||
def get_http_headers(include_all: bool = False) -> dict[str, str]:
|
||||
"""
|
||||
Extract headers from the current HTTP request if available.
|
||||
|
||||
Never raises an exception, even if there is no active HTTP request (in which case
|
||||
an empty dict is returned).
|
||||
|
||||
By default, strips problematic headers like `content-length` that cause issues if forwarded to downstream clients.
|
||||
If `include_all` is True, all headers are returned.
|
||||
"""
|
||||
if include_all:
|
||||
exclude_headers = set()
|
||||
else:
|
||||
exclude_headers = {
|
||||
"host",
|
||||
"content-length",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"te",
|
||||
"keep-alive",
|
||||
"expect",
|
||||
"accept",
|
||||
# Proxy-related headers
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
# MCP-related headers
|
||||
"mcp-session-id",
|
||||
}
|
||||
# (just in case)
|
||||
if not all(h.lower() == h for h in exclude_headers):
|
||||
raise ValueError("Excluded headers must be lowercase")
|
||||
headers = {}
|
||||
|
||||
try:
|
||||
request = get_http_request()
|
||||
for name, value in request.headers.items():
|
||||
lower_name = name.lower()
|
||||
if lower_name not in exclude_headers:
|
||||
headers[lower_name] = str(value)
|
||||
return headers
|
||||
except RuntimeError:
|
||||
return {}
|
||||
|
||||
|
||||
def get_access_token() -> AccessToken | None:
|
||||
"""
|
||||
Get the FastMCP access token from the current context.
|
||||
|
||||
This function first tries to get the token from the current HTTP request's scope,
|
||||
which is more reliable for long-lived connections where the SDK's auth_context_var
|
||||
may become stale after token refresh. Falls back to the SDK's context var if no
|
||||
request is available.
|
||||
|
||||
Returns:
|
||||
The access token if an authenticated user is available, None otherwise.
|
||||
"""
|
||||
access_token: _SDKAccessToken | None = None
|
||||
|
||||
# First, try to get from current HTTP request's scope (issue #1863)
|
||||
# This is more reliable than auth_context_var for Streamable HTTP sessions
|
||||
# where tokens may be refreshed between MCP messages
|
||||
try:
|
||||
request = get_http_request()
|
||||
user = request.scope.get("user")
|
||||
if isinstance(user, AuthenticatedUser):
|
||||
access_token = user.access_token
|
||||
except RuntimeError:
|
||||
# No HTTP request available, fall back to context var
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
# Fall back to SDK's context var if we didn't get a token from the request
|
||||
if access_token is None:
|
||||
access_token = _sdk_get_access_token()
|
||||
|
||||
if access_token is None or isinstance(access_token, AccessToken):
|
||||
return access_token
|
||||
|
||||
# If the object is not a FastMCP AccessToken, convert it to one if the
|
||||
# fields are compatible (e.g. `claims` is not present in the SDK's AccessToken).
|
||||
# This is a workaround for the case where the SDK or auth provider returns a different type
|
||||
# If it fails, it will raise a TypeError
|
||||
try:
|
||||
access_token_as_dict = access_token.model_dump()
|
||||
return AccessToken(
|
||||
token=access_token_as_dict["token"],
|
||||
client_id=access_token_as_dict["client_id"],
|
||||
scopes=access_token_as_dict["scopes"],
|
||||
# Optional fields
|
||||
expires_at=access_token_as_dict.get("expires_at"),
|
||||
resource_owner=access_token_as_dict.get("resource_owner"), # type: ignore[call-arg] # Optional field in MCP SDK
|
||||
claims=access_token_as_dict.get("claims"),
|
||||
)
|
||||
except Exception as e:
|
||||
raise TypeError(
|
||||
f"Expected fastmcp.server.auth.auth.AccessToken, got {type(access_token).__name__}. "
|
||||
"Ensure the SDK is using the correct AccessToken type."
|
||||
) from e
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import anyio
|
|||
import httpx
|
||||
import mcp.types
|
||||
import uvicorn
|
||||
from docket import Docket, Worker
|
||||
from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
|
@ -94,6 +93,8 @@ from fastmcp.utilities.types import NotSet, NotSetT
|
|||
from fastmcp.utilities.visibility import VisibilityFilter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.client import FastMCP1Server
|
||||
from fastmcp.client.sampling import SamplingHandler
|
||||
|
|
@ -457,19 +458,60 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
@asynccontextmanager
|
||||
async def _docket_lifespan(self) -> AsyncIterator[None]:
|
||||
"""Manage Docket instance and Worker for background task execution."""
|
||||
from fastmcp import settings
|
||||
"""Manage Docket instance and Worker for background task execution.
|
||||
|
||||
# Set FastMCP server in ContextVar so CurrentFastMCP can access it (use weakref to avoid reference cycles)
|
||||
from fastmcp.server.dependencies import (
|
||||
_current_docket,
|
||||
_current_server,
|
||||
_current_worker,
|
||||
)
|
||||
Docket infrastructure is only initialized if:
|
||||
1. pydocket is installed (fastmcp[tasks] extra)
|
||||
2. There are task-enabled components (task_config.mode != 'forbidden')
|
||||
|
||||
This means users with pydocket installed but no task-enabled components
|
||||
won't spin up Docket/Worker infrastructure.
|
||||
"""
|
||||
from fastmcp.server.dependencies import _current_server, is_docket_available
|
||||
|
||||
# Set FastMCP server in ContextVar so CurrentFastMCP can access it
|
||||
# (use weakref to avoid reference cycles)
|
||||
server_token = _current_server.set(weakref.ref(self))
|
||||
|
||||
try:
|
||||
# If docket is not available, skip task infrastructure
|
||||
if not is_docket_available():
|
||||
yield
|
||||
return
|
||||
|
||||
# Collect task-enabled components from all providers at startup.
|
||||
# Components must be available now to be registered with Docket workers;
|
||||
# dynamically added components after startup won't be registered.
|
||||
task_results = await gather(
|
||||
*[p.get_tasks() for p in self._providers],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# Flatten and filter results, collecting components and errors
|
||||
task_components: list[FastMCPComponent] = []
|
||||
for i, result in enumerate(task_results):
|
||||
if isinstance(result, BaseException):
|
||||
provider = self._providers[i]
|
||||
logger.warning(f"Failed to get tasks from {provider}: {result}")
|
||||
if fastmcp.settings.mounted_components_raise_on_load_error:
|
||||
raise result
|
||||
continue
|
||||
task_components.extend(result)
|
||||
|
||||
# If no task-enabled components, skip Docket infrastructure entirely
|
||||
if not task_components:
|
||||
yield
|
||||
return
|
||||
|
||||
# Docket is available AND there are task-enabled components
|
||||
from docket import Docket, Worker
|
||||
|
||||
from fastmcp import settings
|
||||
from fastmcp.server.dependencies import (
|
||||
_current_docket,
|
||||
_current_worker,
|
||||
)
|
||||
|
||||
# Create Docket instance using configured name and URL
|
||||
async with Docket(
|
||||
name=settings.docket.name,
|
||||
|
|
@ -478,23 +520,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
# Store on server instance for cross-task access (FastMCPTransport)
|
||||
self._docket = docket
|
||||
|
||||
# Register task-enabled components from all providers in parallel
|
||||
task_results = await gather(
|
||||
*[p.get_tasks() for p in self._providers],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
for i, result in enumerate(task_results):
|
||||
if isinstance(result, BaseException):
|
||||
provider = self._providers[i]
|
||||
logger.warning(
|
||||
f"Failed to register tasks from {provider}: {result}"
|
||||
)
|
||||
if fastmcp.settings.mounted_components_raise_on_load_error:
|
||||
raise result
|
||||
continue
|
||||
for component in result:
|
||||
component.register_with_docket(docket)
|
||||
# Register task-enabled components with Docket
|
||||
for component in task_components:
|
||||
component.register_with_docket(docket)
|
||||
|
||||
# Set Docket in ContextVar so CurrentDocket can access it
|
||||
docket_token = _current_docket.set(docket)
|
||||
|
|
@ -638,7 +666,16 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
self._setup_task_protocol_handlers()
|
||||
|
||||
def _setup_task_protocol_handlers(self) -> None:
|
||||
"""Register SEP-1686 task protocol handlers with SDK."""
|
||||
"""Register SEP-1686 task protocol handlers with SDK.
|
||||
|
||||
Only registers handlers if docket is installed. Without docket,
|
||||
task protocol requests will return "method not found" errors.
|
||||
"""
|
||||
from fastmcp.server.dependencies import is_docket_available
|
||||
|
||||
if not is_docket_available():
|
||||
return
|
||||
|
||||
from mcp.types import (
|
||||
CancelTaskRequest,
|
||||
GetTaskPayloadRequest,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,26 @@
|
|||
"""SEP-1686 task capabilities declaration."""
|
||||
|
||||
from importlib.util import find_spec
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _is_docket_available() -> bool:
|
||||
"""Check if pydocket is installed (local to avoid circular import)."""
|
||||
return find_spec("docket") is not None
|
||||
|
||||
|
||||
def get_task_capabilities() -> dict[str, Any]:
|
||||
"""Return the SEP-1686 task capabilities structure.
|
||||
|
||||
This is the standard capabilities map advertised to clients,
|
||||
declaring support for list, cancel, and request operations.
|
||||
|
||||
Returns empty dict if pydocket is not installed, so clients
|
||||
won't see task support advertised.
|
||||
"""
|
||||
if not _is_docket_available():
|
||||
return {}
|
||||
|
||||
return {
|
||||
"tasks": {
|
||||
"list": {},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ handle task-augmented execution as specified in SEP-1686.
|
|||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
|
@ -50,6 +51,11 @@ class TaskConfig:
|
|||
- "required": Component requires task execution. Clients must request task
|
||||
augmentation; server returns -32601 if they don't.
|
||||
|
||||
Important:
|
||||
Task-enabled components must be available at server startup to be
|
||||
registered with all Docket workers. Components added dynamically after
|
||||
startup will not be registered for background execution.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -93,19 +99,30 @@ class TaskConfig:
|
|||
def validate_function(self, fn: Callable[..., Any], name: str) -> None:
|
||||
"""Validate that function is compatible with this task config.
|
||||
|
||||
Task execution requires async functions. Raises ValueError if mode
|
||||
is "optional" or "required" but function is synchronous.
|
||||
Task execution requires:
|
||||
1. fastmcp[tasks] to be installed (pydocket)
|
||||
2. Async functions
|
||||
|
||||
Raises ImportError if mode is "optional" or "required" but pydocket
|
||||
is not installed. Raises ValueError if function is synchronous.
|
||||
|
||||
Args:
|
||||
fn: The function to validate (handles callable classes and staticmethods).
|
||||
name: Name for error messages.
|
||||
|
||||
Raises:
|
||||
ImportError: If task execution is enabled but pydocket not installed.
|
||||
ValueError: If task execution is enabled but function is sync.
|
||||
"""
|
||||
if not self.supports_tasks():
|
||||
return
|
||||
|
||||
# Check that docket is available for task execution
|
||||
# Lazy import to avoid circular: dependencies.py → http.py → tasks/__init__.py → config.py
|
||||
from fastmcp.server.dependencies import require_docket
|
||||
|
||||
require_docket(f"`task=True` on function '{name}'")
|
||||
|
||||
# Unwrap callable classes and staticmethods
|
||||
fn_to_check = fn
|
||||
if not inspect.isroutine(fn) and callable(fn):
|
||||
|
|
@ -118,3 +135,18 @@ class TaskConfig:
|
|||
f"'{name}' uses a sync function but has task execution enabled. "
|
||||
"Background tasks require async functions."
|
||||
)
|
||||
|
||||
# Warn if function uses Context - it won't be available in workers
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.utilities.types import find_kwarg_by_type
|
||||
|
||||
context_kwarg = find_kwarg_by_type(fn_to_check, Context)
|
||||
if context_kwarg:
|
||||
warnings.warn(
|
||||
f"'{name}' uses Context but has task execution enabled. "
|
||||
"Context is not available in background task workers because "
|
||||
"there is no active MCP session. Consider using Docket dependencies "
|
||||
"like Progress() instead for worker-compatible functionality.",
|
||||
UserWarning,
|
||||
stacklevel=4,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Handles MCP task protocol requests: tasks/get, tasks/result, tasks/list, tasks/cancel.
|
||||
These handlers query and manage existing tasks (contrast with handlers.py which creates tasks).
|
||||
|
||||
This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -21,12 +23,18 @@ from mcp.types import (
|
|||
ListTasksResult,
|
||||
)
|
||||
|
||||
import fastmcp.server.context
|
||||
from fastmcp.prompts.prompt import Prompt
|
||||
from fastmcp.resources.resource import Resource
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.server.tasks.config import DEFAULT_POLL_INTERVAL_MS, DEFAULT_TTL_MS
|
||||
from fastmcp.server.tasks.keys import parse_task_key
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
|
||||
# Map Docket execution states to MCP task status strings
|
||||
# Per SEP-1686 final spec (line 381): tasks MUST begin in "working" status
|
||||
DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = {
|
||||
|
|
@ -115,8 +123,6 @@ async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskR
|
|||
Returns:
|
||||
GetTaskResult: Task status response with spec-compliant fields
|
||||
"""
|
||||
import fastmcp.server.context
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=server) as ctx:
|
||||
client_task_id = params.get("taskId")
|
||||
if not client_task_id:
|
||||
|
|
@ -148,9 +154,10 @@ async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskR
|
|||
await execution.sync()
|
||||
|
||||
# Map Docket state to MCP state
|
||||
state_map = DOCKET_TO_MCP_STATE
|
||||
mcp_state: Literal[
|
||||
"working", "input_required", "completed", "failed", "cancelled"
|
||||
] = DOCKET_TO_MCP_STATE.get(execution.state, "failed") # type: ignore[assignment]
|
||||
] = state_map.get(execution.state, "failed") # type: ignore[assignment]
|
||||
|
||||
# Build response (use default ttl since we don't track per-task values)
|
||||
# createdAt is REQUIRED per SEP-1686 final spec (line 430)
|
||||
|
|
@ -203,8 +210,6 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
|
|||
Returns:
|
||||
MCP result (CallToolResult, GetPromptResult, or ReadResourceResult)
|
||||
"""
|
||||
import fastmcp.server.context
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=server) as ctx:
|
||||
client_task_id = params.get("taskId")
|
||||
if not client_task_id:
|
||||
|
|
@ -255,8 +260,9 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
|
|||
await execution.sync()
|
||||
|
||||
# Check if completed
|
||||
state_map = DOCKET_TO_MCP_STATE
|
||||
if execution.state not in (ExecutionState.COMPLETED, ExecutionState.FAILED):
|
||||
mcp_state = DOCKET_TO_MCP_STATE.get(execution.state, "failed")
|
||||
mcp_state = state_map.get(execution.state, "failed")
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=INVALID_PARAMS,
|
||||
|
|
@ -284,11 +290,6 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
|
|||
component_key = key_parts["component_identifier"]
|
||||
|
||||
# Look up component by its prefixed key
|
||||
from fastmcp.prompts.prompt import Prompt
|
||||
from fastmcp.resources.resource import Resource
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
||||
component = await server.get_component(component_key)
|
||||
|
||||
# Build related-task metadata
|
||||
|
|
@ -378,8 +379,6 @@ async def tasks_cancel_handler(
|
|||
Returns:
|
||||
CancelTaskResult: Task status response showing cancelled state
|
||||
"""
|
||||
import fastmcp.server.context
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=server) as ctx:
|
||||
client_task_id = params.get("taskId")
|
||||
if not client_task_id:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Subscribes to Docket execution state changes and sends notifications/tasks/status
|
||||
to clients when their tasks change state.
|
||||
|
||||
This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -13,6 +15,7 @@ from typing import TYPE_CHECKING
|
|||
from docket.execution import ExecutionState
|
||||
from mcp.types import TaskStatusNotification, TaskStatusNotificationParams
|
||||
|
||||
from fastmcp.server.tasks.keys import parse_task_key
|
||||
from fastmcp.server.tasks.requests import DOCKET_TO_MCP_STATE
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
|
|
@ -99,11 +102,10 @@ async def _send_status_notification(
|
|||
poll_interval_ms: Poll interval in milliseconds
|
||||
"""
|
||||
# Map Docket state to MCP status
|
||||
mcp_status = DOCKET_TO_MCP_STATE.get(state, "failed")
|
||||
state_map = DOCKET_TO_MCP_STATE
|
||||
mcp_status = state_map.get(state, "failed")
|
||||
|
||||
# Extract session_id from task_key for Redis lookup
|
||||
from fastmcp.server.tasks.keys import parse_task_key
|
||||
|
||||
key_parts = parse_task_key(task_key)
|
||||
session_id = key_parts["session_id"]
|
||||
|
||||
|
|
@ -174,11 +176,10 @@ async def _send_progress_notification(
|
|||
return
|
||||
|
||||
# Map Docket state to MCP status
|
||||
mcp_status = DOCKET_TO_MCP_STATE.get(execution.state, "failed")
|
||||
state_map = DOCKET_TO_MCP_STATE
|
||||
mcp_status = state_map.get(execution.state, "failed")
|
||||
|
||||
# Extract session_id from task_key for Redis lookup
|
||||
from fastmcp.server.tasks.keys import parse_task_key
|
||||
|
||||
key_parts = parse_task_key(task_key)
|
||||
session_id = key_parts["session_id"]
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,10 @@ from pydantic import BaseModel, Field, PydanticSchemaGenerationError, model_vali
|
|||
from typing_extensions import TypeVar
|
||||
|
||||
import fastmcp
|
||||
from fastmcp.server.dependencies import without_injected_parameters
|
||||
from fastmcp.server.dependencies import (
|
||||
transform_context_annotations,
|
||||
without_injected_parameters,
|
||||
)
|
||||
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.json_schema import compress_schema
|
||||
|
|
@ -611,6 +614,9 @@ class ParsedFunction:
|
|||
if isinstance(fn, staticmethod):
|
||||
fn = fn.__func__
|
||||
|
||||
# Transform Context type annotations to Depends() for unified DI
|
||||
fn = transform_context_annotations(fn)
|
||||
|
||||
# Handle injected parameters (Context, Docket dependencies)
|
||||
wrapper_fn = without_injected_parameters(fn)
|
||||
|
||||
|
|
|
|||
|
|
@ -745,3 +745,302 @@ async def test_validation_error_propagates_from_dependency(mcp: FastMCP):
|
|||
assert result.is_error
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Invalid input format"
|
||||
|
||||
|
||||
# --- Tests for transform_context_annotations ---
|
||||
|
||||
|
||||
class TestTransformContextAnnotations:
|
||||
"""Tests for the transform_context_annotations function."""
|
||||
|
||||
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."""
|
||||
import inspect
|
||||
|
||||
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."""
|
||||
import inspect
|
||||
|
||||
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
|
||||
tools = await mcp._list_tools_mcp()
|
||||
tool = next(t for t in 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."""
|
||||
import inspect
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestVendoredDI:
|
||||
"""Tests for vendored DI when docket is not installed."""
|
||||
|
||||
def test_is_docket_available(self):
|
||||
"""Test is_docket_available returns True when docket is installed."""
|
||||
from fastmcp.server.dependencies import is_docket_available
|
||||
|
||||
# In dev environment, docket should be available
|
||||
assert is_docket_available() is True
|
||||
|
||||
def test_require_docket_passes_when_installed(self):
|
||||
"""Test require_docket doesn't raise when docket is installed."""
|
||||
from fastmcp.server.dependencies import require_docket
|
||||
|
||||
# Should not raise
|
||||
require_docket("test feature")
|
||||
|
||||
def test_vendored_dependency_class_exists(self):
|
||||
"""Test vendored Dependency class is importable."""
|
||||
from fastmcp._vendor.docket_di import Dependency, Depends
|
||||
|
||||
assert Dependency is not None
|
||||
assert Depends is not None
|
||||
|
||||
def test_vendored_depends_works(self):
|
||||
"""Test vendored Depends() creates proper dependency wrapper."""
|
||||
from fastmcp._vendor.docket_di import Depends, _Depends
|
||||
|
||||
def get_value() -> str:
|
||||
return "test_value"
|
||||
|
||||
dep = Depends(get_value)
|
||||
assert isinstance(dep, _Depends)
|
||||
assert dep.dependency is get_value
|
||||
|
||||
async def test_depends_import_fallback(self):
|
||||
"""Test that Depends can be imported from fastmcp.dependencies."""
|
||||
# This tests the import path, not the actual fallback behavior
|
||||
# since docket is always installed in dev
|
||||
from fastmcp.dependencies import Depends
|
||||
|
||||
def get_config() -> dict:
|
||||
return {"key": "value"}
|
||||
|
||||
dep = Depends(get_config)
|
||||
# Should work regardless of whether docket or vendored is used
|
||||
assert dep is not None
|
||||
|
||||
def test_vendored_get_dependency_parameters(self):
|
||||
"""Test vendored get_dependency_parameters finds dependency defaults."""
|
||||
from fastmcp._vendor.docket_di import (
|
||||
Depends,
|
||||
_Depends,
|
||||
get_dependency_parameters,
|
||||
)
|
||||
|
||||
def get_db() -> str:
|
||||
return "database"
|
||||
|
||||
def my_func(name: str, db: str = Depends(get_db)) -> str:
|
||||
return f"{name}: {db}"
|
||||
|
||||
deps = get_dependency_parameters(my_func)
|
||||
assert "db" in deps
|
||||
db_dep = deps["db"]
|
||||
assert isinstance(db_dep, _Depends)
|
||||
assert db_dep.dependency is get_db
|
||||
|
|
|
|||
|
|
@ -963,8 +963,9 @@ class TestAsProxyKwarg:
|
|||
async with Client(mcp) as client:
|
||||
await client.call_tool("hello", {})
|
||||
|
||||
# Lifespan is entered exactly once and kept alive by Docket worker
|
||||
assert lifespan_check == ["start"]
|
||||
# Lifespan is executed at least once (may be multiple times for proxy connections)
|
||||
assert len(lifespan_check) >= 1
|
||||
assert all(x == "start" for x in lifespan_check)
|
||||
|
||||
|
||||
class TestResourceUriPrefixing:
|
||||
|
|
@ -1428,6 +1429,11 @@ class TestMountedServerDocketBehavior:
|
|||
main_app = FastMCP("MainApp")
|
||||
sub_app = FastMCP("SubApp")
|
||||
|
||||
# Need a task-enabled component to trigger Docket initialization
|
||||
@main_app.tool(task=True)
|
||||
async def _trigger_docket() -> str:
|
||||
return "trigger"
|
||||
|
||||
@sub_app.tool
|
||||
def my_tool() -> str:
|
||||
return "test"
|
||||
|
|
@ -1438,6 +1444,7 @@ class TestMountedServerDocketBehavior:
|
|||
# its own Docket instance
|
||||
async with Client(main_app) as client:
|
||||
# The main app should have a docket (created by _lifespan_manager)
|
||||
# because it has a task-enabled component
|
||||
assert main_app.docket is not None
|
||||
|
||||
# The mounted sub app should NOT have its own docket
|
||||
|
|
|
|||
|
|
@ -14,10 +14,32 @@ from fastmcp.server.dependencies import get_context
|
|||
HUZZAH = "huzzah!"
|
||||
|
||||
|
||||
async def test_docket_not_initialized_without_task_components():
|
||||
"""Docket is only initialized when task-enabled components exist."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
@mcp.tool()
|
||||
def regular_tool() -> str:
|
||||
return "no docket needed"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Docket should not be initialized
|
||||
assert mcp._docket is None
|
||||
|
||||
# Regular tools still work
|
||||
result = await client.call_tool("regular_tool", {})
|
||||
assert result.data == "no docket needed"
|
||||
|
||||
|
||||
async def test_current_docket():
|
||||
"""CurrentDocket dependency provides access to Docket instance."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
# Need a task-enabled component to trigger Docket initialization
|
||||
@mcp.tool(task=True)
|
||||
async def _trigger_docket() -> str:
|
||||
return "trigger"
|
||||
|
||||
@mcp.tool()
|
||||
def check_docket(docket: Docket = CurrentDocket()) -> str:
|
||||
assert isinstance(docket, Docket)
|
||||
|
|
@ -32,6 +54,11 @@ async def test_current_worker():
|
|||
"""CurrentWorker dependency provides access to Worker instance."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
# Need a task-enabled component to trigger Docket initialization
|
||||
@mcp.tool(task=True)
|
||||
async def _trigger_docket() -> str:
|
||||
return "trigger"
|
||||
|
||||
@mcp.tool()
|
||||
def check_worker(
|
||||
worker: Worker = CurrentWorker(),
|
||||
|
|
@ -51,6 +78,11 @@ async def test_worker_executes_background_tasks():
|
|||
task_completed = asyncio.Event()
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
# Need a task-enabled component to trigger Docket initialization
|
||||
@mcp.tool(task=True)
|
||||
async def _trigger_docket() -> str:
|
||||
return "trigger"
|
||||
|
||||
@mcp.tool()
|
||||
async def schedule_work(
|
||||
task_name: str,
|
||||
|
|
@ -79,6 +111,11 @@ async def test_current_docket_in_resource():
|
|||
"""CurrentDocket works in resources."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
# Need a task-enabled component to trigger Docket initialization
|
||||
@mcp.tool(task=True)
|
||||
async def _trigger_docket() -> str:
|
||||
return "trigger"
|
||||
|
||||
@mcp.resource("docket://info")
|
||||
def get_docket_info(docket: Docket = CurrentDocket()) -> str:
|
||||
assert isinstance(docket, Docket)
|
||||
|
|
@ -93,6 +130,11 @@ async def test_current_docket_in_prompt():
|
|||
"""CurrentDocket works in prompts."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
# Need a task-enabled component to trigger Docket initialization
|
||||
@mcp.tool(task=True)
|
||||
async def _trigger_docket() -> str:
|
||||
return "trigger"
|
||||
|
||||
@mcp.prompt()
|
||||
def task_prompt(task_type: str, docket: Docket = CurrentDocket()) -> str:
|
||||
assert isinstance(docket, Docket)
|
||||
|
|
@ -107,6 +149,11 @@ async def test_current_docket_in_resource_template():
|
|||
"""CurrentDocket works in resource templates."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
# Need a task-enabled component to trigger Docket initialization
|
||||
@mcp.tool(task=True)
|
||||
async def _trigger_docket() -> str:
|
||||
return "trigger"
|
||||
|
||||
@mcp.resource("docket://tasks/{task_id}")
|
||||
def get_task_status(task_id: str, docket: Docket = CurrentDocket()) -> str:
|
||||
assert isinstance(docket, Docket)
|
||||
|
|
@ -122,6 +169,11 @@ async def test_concurrent_calls_maintain_isolation():
|
|||
mcp = FastMCP("test-server")
|
||||
docket_ids = []
|
||||
|
||||
# Need a task-enabled component to trigger Docket initialization
|
||||
@mcp.tool(task=True)
|
||||
async def _trigger_docket() -> str:
|
||||
return "trigger"
|
||||
|
||||
@mcp.tool()
|
||||
def capture_docket_id(call_num: int, docket: Docket = CurrentDocket()) -> str:
|
||||
docket_ids.append((call_num, id(docket)))
|
||||
|
|
@ -155,6 +207,11 @@ async def test_user_lifespan_still_works_with_docket():
|
|||
|
||||
mcp = FastMCP("test-server", lifespan=custom_lifespan)
|
||||
|
||||
# Need a task-enabled component to trigger Docket initialization
|
||||
@mcp.tool(task=True)
|
||||
async def _trigger_docket() -> str:
|
||||
return "trigger"
|
||||
|
||||
@mcp.tool()
|
||||
def check_both(docket: Docket = CurrentDocket()) -> str:
|
||||
assert isinstance(docket, Docket)
|
||||
|
|
|
|||
18
uv.lock
generated
18
uv.lock
generated
|
|
@ -696,7 +696,6 @@ dependencies = [
|
|||
{ name = "platformdirs" },
|
||||
{ name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pydocket" },
|
||||
{ name = "pyperclip" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "rich" },
|
||||
|
|
@ -712,12 +711,15 @@ anthropic = [
|
|||
openai = [
|
||||
{ name = "openai" },
|
||||
]
|
||||
tasks = [
|
||||
{ name = "pydocket" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "dirty-equals" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastmcp", extra = ["anthropic", "openai"] },
|
||||
{ name = "fastmcp", extra = ["anthropic", "openai", "tasks"] },
|
||||
{ name = "inline-snapshot", extra = ["dirty-equals"] },
|
||||
{ name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "ipython", version = "9.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
|
|
@ -755,7 +757,7 @@ requires-dist = [
|
|||
{ name = "platformdirs", specifier = ">=4.0.0" },
|
||||
{ name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.3.0,<0.4.0" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
|
||||
{ name = "pydocket", specifier = ">=0.16.4" },
|
||||
{ name = "pydocket", marker = "extra == 'tasks'", specifier = ">=0.16.4" },
|
||||
{ name = "pyperclip", specifier = ">=1.9.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
||||
{ name = "rich", specifier = ">=13.9.4" },
|
||||
|
|
@ -763,13 +765,13 @@ requires-dist = [
|
|||
{ name = "watchfiles", specifier = ">=1.0.0" },
|
||||
{ name = "websockets", specifier = ">=15.0.1" },
|
||||
]
|
||||
provides-extras = ["anthropic", "openai"]
|
||||
provides-extras = ["anthropic", "openai", "tasks"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "dirty-equals", specifier = ">=0.9.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.12" },
|
||||
{ name = "fastmcp", extras = ["anthropic", "openai"] },
|
||||
{ name = "fastmcp", extras = ["anthropic", "openai", "tasks"] },
|
||||
{ name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" },
|
||||
{ name = "ipython", specifier = ">=8.12.3" },
|
||||
{ name = "pdbpp", specifier = ">=0.11.7" },
|
||||
|
|
@ -1788,7 +1790,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pydocket"
|
||||
version = "0.16.4"
|
||||
version = "0.16.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cloudpickle" },
|
||||
|
|
@ -1805,9 +1807,9 @@ dependencies = [
|
|||
{ name = "typer" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/c6/eb7f3af72fa5c04b52a3f9390ff0c948987441987f9526dd992d2a6b3524/pydocket-0.16.4.tar.gz", hash = "sha256:d034d1ac75877560d86329fb3643e7b862fcbcdac407d876a62f5d9e386e8753", size = 297949, upload-time = "2026-01-08T21:58:31.637Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/00/26befe5f58df7cd1aeda4a8d10bc7d1908ffd86b80fd995e57a2a7b3f7bd/pydocket-0.16.6.tar.gz", hash = "sha256:b96c96ad7692827214ed4ff25fcf941ec38371314db5dcc1ae792b3e9d3a0294", size = 299054, upload-time = "2026-01-09T22:09:15.405Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/c5/e6ffed3902ead6cb906758749c42211f7f24ea6d8fdd772f531f5a81c9fa/pydocket-0.16.4-py3-none-any.whl", hash = "sha256:cdcdf74b987c2cd5d03c7353d15f8dd2ac9bd43f2a91f7441748d5a8ebd617c9", size = 67374, upload-time = "2026-01-08T21:58:30.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/3f/7483e5a6dc6326b6e0c640619b5c5bd1d6e3c20e54d58f5fb86267cef00e/pydocket-0.16.6-py3-none-any.whl", hash = "sha256:683d21e2e846aa5106274e7d59210331b242d7fb0dce5b08d3b82065663ed183", size = 67697, upload-time = "2026-01-09T22:09:13.436Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue