Make pydocket optional and unify DI systems (#2835)

This commit is contained in:
Jeremiah Lowin 2026-01-10 16:23:32 -05:00 committed by GitHub
commit 8e1fd1d700
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1251 additions and 313 deletions

View file

@ -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.

View file

@ -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",

View file

@ -0,0 +1 @@
"""Vendored third-party code for FastMCP."""

View 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.

View 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",
]

View file

@ -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",
]

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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,

View file

@ -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": {},

View file

@ -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,
)

View file

@ -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:

View file

@ -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"]

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -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]]