From 8e1fd1d7002e4eb875a012406df98ff5f339fbab Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:23:32 -0500 Subject: [PATCH] Make pydocket optional and unify DI systems (#2835) --- docs/servers/tasks.mdx | 4 + pyproject.toml | 4 +- src/fastmcp/_vendor/__init__.py | 1 + src/fastmcp/_vendor/docket_di/README.md | 7 + src/fastmcp/_vendor/docket_di/__init__.py | 163 +++++ src/fastmcp/dependencies.py | 13 +- src/fastmcp/prompts/prompt.py | 8 +- src/fastmcp/resources/resource.py | 8 +- src/fastmcp/resources/template.py | 8 +- src/fastmcp/server/dependencies.py | 774 +++++++++++++++------- src/fastmcp/server/server.py | 91 ++- src/fastmcp/server/tasks/capabilities.py | 12 + src/fastmcp/server/tasks/config.py | 36 +- src/fastmcp/server/tasks/requests.py | 25 +- src/fastmcp/server/tasks/subscriptions.py | 13 +- src/fastmcp/tools/tool.py | 8 +- tests/server/test_dependencies.py | 299 +++++++++ tests/server/test_mount.py | 11 +- tests/server/test_server_docket.py | 57 ++ uv.lock | 18 +- 20 files changed, 1249 insertions(+), 311 deletions(-) create mode 100644 src/fastmcp/_vendor/__init__.py create mode 100644 src/fastmcp/_vendor/docket_di/README.md create mode 100644 src/fastmcp/_vendor/docket_di/__init__.py diff --git a/docs/servers/tasks.mdx b/docs/servers/tasks.mdx index ab1f135d2..1eab75f62 100644 --- a/docs/servers/tasks.mdx +++ b/docs/servers/tasks.mdx @@ -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. + +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. + + ## 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. diff --git a/pyproject.toml b/pyproject.toml index 4045c6578..09a01a61e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/fastmcp/_vendor/__init__.py b/src/fastmcp/_vendor/__init__.py new file mode 100644 index 000000000..c6d8ad492 --- /dev/null +++ b/src/fastmcp/_vendor/__init__.py @@ -0,0 +1 @@ +"""Vendored third-party code for FastMCP.""" diff --git a/src/fastmcp/_vendor/docket_di/README.md b/src/fastmcp/_vendor/docket_di/README.md new file mode 100644 index 000000000..19e44daf8 --- /dev/null +++ b/src/fastmcp/_vendor/docket_di/README.md @@ -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. diff --git a/src/fastmcp/_vendor/docket_di/__init__.py b/src/fastmcp/_vendor/docket_di/__init__.py new file mode 100644 index 000000000..f005d7487 --- /dev/null +++ b/src/fastmcp/_vendor/docket_di/__init__.py @@ -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", +] diff --git a/src/fastmcp/dependencies.py b/src/fastmcp/dependencies.py index 3cdc7d6b9..b9be7f130 100644 --- a/src/fastmcp/dependencies.py +++ b/src/fastmcp/dependencies.py @@ -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", ] diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index cf7660f6c..7b2f9f456 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -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) diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index b28ee5e10..8d2c023c3 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -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) diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index ef124d18a..4f7c32928 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -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() diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index ec74cff83..d2698670c 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -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 diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c012c809a..d789906f0 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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, diff --git a/src/fastmcp/server/tasks/capabilities.py b/src/fastmcp/server/tasks/capabilities.py index c08371eca..8f49fcc9e 100644 --- a/src/fastmcp/server/tasks/capabilities.py +++ b/src/fastmcp/server/tasks/capabilities.py @@ -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": {}, diff --git a/src/fastmcp/server/tasks/config.py b/src/fastmcp/server/tasks/config.py index 40da12ee8..1bf0b8ce3 100644 --- a/src/fastmcp/server/tasks/config.py +++ b/src/fastmcp/server/tasks/config.py @@ -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, + ) diff --git a/src/fastmcp/server/tasks/requests.py b/src/fastmcp/server/tasks/requests.py index 1f79a4eeb..4cf4b6f68 100644 --- a/src/fastmcp/server/tasks/requests.py +++ b/src/fastmcp/server/tasks/requests.py @@ -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: diff --git a/src/fastmcp/server/tasks/subscriptions.py b/src/fastmcp/server/tasks/subscriptions.py index b2c671d82..ca9921a3b 100644 --- a/src/fastmcp/server/tasks/subscriptions.py +++ b/src/fastmcp/server/tasks/subscriptions.py @@ -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"] diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index c243968b5..576efefe2 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -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) diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index 7be5f479f..66b85dcac 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -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 diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 47f1adcc2..3917d7c5b 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -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 diff --git a/tests/server/test_server_docket.py b/tests/server/test_server_docket.py index 917e773b2..7d0b7f5c0 100644 --- a/tests/server/test_server_docket.py +++ b/tests/server/test_server_docket.py @@ -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) diff --git a/uv.lock b/uv.lock index 8a5e37870..600f0392f 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]