From faf5f86e0913c356181ffb1f922bffe9602059af Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:52:21 -0400 Subject: [PATCH] Version-check is_docket_available() to avoid transitive pydocket crash (#3807) --- src/fastmcp/server/dependencies.py | 54 +++++++++-- src/fastmcp/server/tasks/capabilities.py | 20 ++-- tests/server/tasks/test_task_capabilities.py | 25 +++++ tests/server/test_dependencies.py | 97 ++++++++++++++++++++ 4 files changed, 178 insertions(+), 18 deletions(-) diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index da8de3241..f1e863586 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -8,6 +8,7 @@ CurrentWorker) and background task execution require fastmcp[tasks]. from __future__ import annotations import contextlib +import importlib.metadata import inspect import json import logging @@ -30,6 +31,7 @@ from mcp.server.auth.provider import ( AccessToken as _SDKAccessToken, ) from mcp.server.lowlevel.server import request_ctx +from packaging.version import Version from starlette.requests import Request from uncalled_for import Dependency, get_dependency_parameters from uncalled_for.resolution import _Depends @@ -420,15 +422,34 @@ def _get_sync_redis(url: str) -> Any: _DOCKET_AVAILABLE: bool | None = None +_MIN_DOCKET_VERSION = Version("0.18.0") + + def is_docket_available() -> bool: - """Check if pydocket is installed.""" + """Check if a compatible pydocket (>= 0.18.0) is installed and importable. + + Three things have to be true for fastmcp's task features to work: + 1. pydocket distribution metadata is discoverable + 2. its version is at least ``_MIN_DOCKET_VERSION`` (older versions are + missing symbols like ``docket.dependencies.current_execution``, + which fastmcp imports on the request hot path) + 3. the package actually imports — guards against broken/partial + installs where metadata exists but ``import docket`` blows up + + Any of those failing means we treat docket as unavailable and fall back + to the no-tasks code paths instead of crashing deep inside a request. + """ global _DOCKET_AVAILABLE if _DOCKET_AVAILABLE is None: try: - import docket # noqa: F401 + installed = Version(importlib.metadata.version("pydocket")) + if installed < _MIN_DOCKET_VERSION: + _DOCKET_AVAILABLE = False + else: + import docket # noqa: F401 - _DOCKET_AVAILABLE = True - except ImportError: + _DOCKET_AVAILABLE = True + except (importlib.metadata.PackageNotFoundError, ImportError): _DOCKET_AVAILABLE = False return _DOCKET_AVAILABLE @@ -440,12 +461,27 @@ def require_docket(feature: str) -> None: 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 is_docket_available(): + return + + try: + installed = importlib.metadata.version("pydocket") + except importlib.metadata.PackageNotFoundError: + installed = None + + if installed is None: + detail = ( + "FastMCP background tasks require the `tasks` extra. " + "Install with: pip install 'fastmcp[tasks]'." ) + else: + detail = ( + f"FastMCP background tasks require pydocket>={_MIN_DOCKET_VERSION}, " + f"but pydocket {installed} is installed (likely pulled in by another " + f"package). Upgrade with: pip install -U 'pydocket>={_MIN_DOCKET_VERSION}'." + ) + + raise ImportError(f"{detail} (Triggered by {feature})") # Import Progress separately — it's docket-specific, not part of uncalled-for diff --git a/src/fastmcp/server/tasks/capabilities.py b/src/fastmcp/server/tasks/capabilities.py index 48c1f3d71..f30ef58d3 100644 --- a/src/fastmcp/server/tasks/capabilities.py +++ b/src/fastmcp/server/tasks/capabilities.py @@ -1,7 +1,5 @@ """SEP-1686 task capabilities declaration.""" -from importlib.util import find_spec - from mcp.types import ( ServerTasksCapability, ServerTasksRequestsCapability, @@ -12,23 +10,27 @@ from mcp.types import ( ) -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() -> ServerTasksCapability | None: """Return the SEP-1686 task capabilities. Returns task capabilities as a first-class ServerCapabilities field, declaring support for list, cancel, and request operations per SEP-1686. - Returns None if pydocket is not installed (no task support). + Returns None if a compatible pydocket is not installed (no task support). + Uses the canonical ``is_docket_available()`` check so that capability + advertisement and handler registration stay in sync — otherwise a server + with an old transitive pydocket would advertise task support and then + return "method not found" when clients invoked it. Note: prompts/resources are passed via extra_data since the SDK types don't include them yet (FastMCP supports them ahead of the spec). """ - if not _is_docket_available(): + # Function-local import to avoid a circular import at module load time: + # fastmcp.server.tasks.__init__ pulls in this module, and dependencies + # transitively reaches back into fastmcp.server.tasks.keys. + from fastmcp.server.dependencies import is_docket_available + + if not is_docket_available(): return None return ServerTasksCapability( diff --git a/tests/server/tasks/test_task_capabilities.py b/tests/server/tasks/test_task_capabilities.py index 146d80d41..b8b432a7f 100644 --- a/tests/server/tasks/test_task_capabilities.py +++ b/tests/server/tasks/test_task_capabilities.py @@ -42,3 +42,28 @@ async def test_client_uses_task_capable_session(): assert client.initialize_result is not None # Session should be a ClientSession (task-capable init uses standard session) assert type(client.session).__name__ == "ClientSession" + + +def test_capabilities_hidden_when_pydocket_too_old(monkeypatch): + """Capability advertisement and handler registration must agree. + + If ``is_docket_available()`` returns False (e.g. an old transitive + pydocket), the server skips registering task handlers — so it must + also stop advertising task capabilities, or clients would discover + task support and then hit "method not found" at runtime. + """ + import importlib.metadata + + from fastmcp.server import dependencies + + original_version = importlib.metadata.version + + def fake_version(name: str) -> str: + if name == "pydocket": + return "0.16.6" + return original_version(name) + + monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None) + monkeypatch.setattr(importlib.metadata, "version", fake_version) + + assert get_task_capabilities() is None diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index 9a4af015b..021d67b9a 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -757,12 +757,109 @@ class TestDependencyInjection: assert is_docket_available() is True + def test_is_docket_available_false_when_pydocket_too_old(self, monkeypatch): + """``is_docket_available()`` must treat pre-0.18.0 pydocket as unavailable. + + Older pydocket versions (e.g. 0.16.x, pulled in transitively by + packages like prefect) import cleanly but lack the APIs fastmcp + uses (``docket.dependencies.current_execution``, etc.). Without a + version floor, the check would report available and then crash at + runtime. Simulate by forcing ``importlib.metadata`` to report an + old version. + """ + import importlib.metadata + + from fastmcp.server import dependencies + + original_version = importlib.metadata.version + + def fake_version(name: str) -> str: + if name == "pydocket": + return "0.16.6" + return original_version(name) + + monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None) + monkeypatch.setattr(importlib.metadata, "version", fake_version) + + assert dependencies.is_docket_available() is False + # The wrapper that actually failed in #3803 must now return None + # instead of raising ImportError on the inner import. + assert dependencies.get_task_context() is None + + def test_is_docket_available_false_when_pydocket_not_installed(self, monkeypatch): + """``is_docket_available()`` returns False when pydocket is absent.""" + import importlib.metadata + + from fastmcp.server import dependencies + + original_version = importlib.metadata.version + + def fake_version(name: str) -> str: + if name == "pydocket": + raise importlib.metadata.PackageNotFoundError(name) + return original_version(name) + + monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None) + monkeypatch.setattr(importlib.metadata, "version", fake_version) + + assert dependencies.is_docket_available() is False + + def test_is_docket_available_false_when_import_broken(self, monkeypatch): + """Metadata says installed but ``import docket`` fails — treat as unavailable. + + Catches the broken/partial-install case where ``importlib.metadata`` + still reports a usable version but the package itself isn't actually + importable (corrupted wheel, sys.path weirdness, etc.). Without the + import probe, fastmcp would later crash on its first ``from docket + ...`` instead of falling back gracefully. + """ + import builtins + + from fastmcp.server import dependencies + + original_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "docket" or name.startswith("docket."): + raise ImportError("simulated broken docket install") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None) + monkeypatch.setattr(builtins, "__import__", fake_import) + + assert dependencies.is_docket_available() is False + 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 require_docket("test feature") + def test_require_docket_error_mentions_version_when_too_old(self, monkeypatch): + """``require_docket()`` distinguishes "missing" from "too old". + + When pydocket is installed but pinned below the floor, the install + instructions in the error must point at upgrading pydocket — not at + installing the ``tasks`` extra (which the resolver will treat as a + no-op as long as the lower pin is held by another package). + """ + import importlib.metadata + + from fastmcp.server import dependencies + + original_version = importlib.metadata.version + + def fake_version(name: str) -> str: + if name == "pydocket": + return "0.16.6" + return original_version(name) + + monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None) + monkeypatch.setattr(importlib.metadata, "version", fake_version) + + with pytest.raises(ImportError, match="pydocket 0.16.6 is installed"): + dependencies.require_docket("CurrentDocket()") + def test_dependency_class_exists(self): """Test Dependency and Depends are importable from fastmcp.""" from fastmcp.dependencies import Dependency, Depends