Skip to content

Commit faf5f86

Browse files
authored
Version-check is_docket_available() to avoid transitive pydocket crash (#3807)
1 parent ce9c4bc commit faf5f86

4 files changed

Lines changed: 178 additions & 18 deletions

File tree

src/fastmcp/server/dependencies.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import contextlib
11+
import importlib.metadata
1112
import inspect
1213
import json
1314
import logging
@@ -30,6 +31,7 @@
3031
AccessToken as _SDKAccessToken,
3132
)
3233
from mcp.server.lowlevel.server import request_ctx
34+
from packaging.version import Version
3335
from starlette.requests import Request
3436
from uncalled_for import Dependency, get_dependency_parameters
3537
from uncalled_for.resolution import _Depends
@@ -420,15 +422,34 @@ def _get_sync_redis(url: str) -> Any:
420422
_DOCKET_AVAILABLE: bool | None = None
421423

422424

425+
_MIN_DOCKET_VERSION = Version("0.18.0")
426+
427+
423428
def is_docket_available() -> bool:
424-
"""Check if pydocket is installed."""
429+
"""Check if a compatible pydocket (>= 0.18.0) is installed and importable.
430+
431+
Three things have to be true for fastmcp's task features to work:
432+
1. pydocket distribution metadata is discoverable
433+
2. its version is at least ``_MIN_DOCKET_VERSION`` (older versions are
434+
missing symbols like ``docket.dependencies.current_execution``,
435+
which fastmcp imports on the request hot path)
436+
3. the package actually imports — guards against broken/partial
437+
installs where metadata exists but ``import docket`` blows up
438+
439+
Any of those failing means we treat docket as unavailable and fall back
440+
to the no-tasks code paths instead of crashing deep inside a request.
441+
"""
425442
global _DOCKET_AVAILABLE
426443
if _DOCKET_AVAILABLE is None:
427444
try:
428-
import docket # noqa: F401
445+
installed = Version(importlib.metadata.version("pydocket"))
446+
if installed < _MIN_DOCKET_VERSION:
447+
_DOCKET_AVAILABLE = False
448+
else:
449+
import docket # noqa: F401
429450

430-
_DOCKET_AVAILABLE = True
431-
except ImportError:
451+
_DOCKET_AVAILABLE = True
452+
except (importlib.metadata.PackageNotFoundError, ImportError):
432453
_DOCKET_AVAILABLE = False
433454
return _DOCKET_AVAILABLE
434455

@@ -440,12 +461,27 @@ def require_docket(feature: str) -> None:
440461
feature: Description of what requires docket (e.g., "`task=True`",
441462
"CurrentDocket()"). Will be included in the error message.
442463
"""
443-
if not is_docket_available():
444-
raise ImportError(
445-
f"FastMCP background tasks require the `tasks` extra. "
446-
f"Install with: pip install 'fastmcp[tasks]'. "
447-
f"(Triggered by {feature})"
464+
if is_docket_available():
465+
return
466+
467+
try:
468+
installed = importlib.metadata.version("pydocket")
469+
except importlib.metadata.PackageNotFoundError:
470+
installed = None
471+
472+
if installed is None:
473+
detail = (
474+
"FastMCP background tasks require the `tasks` extra. "
475+
"Install with: pip install 'fastmcp[tasks]'."
448476
)
477+
else:
478+
detail = (
479+
f"FastMCP background tasks require pydocket>={_MIN_DOCKET_VERSION}, "
480+
f"but pydocket {installed} is installed (likely pulled in by another "
481+
f"package). Upgrade with: pip install -U 'pydocket>={_MIN_DOCKET_VERSION}'."
482+
)
483+
484+
raise ImportError(f"{detail} (Triggered by {feature})")
449485

450486

451487
# Import Progress separately — it's docket-specific, not part of uncalled-for

src/fastmcp/server/tasks/capabilities.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
"""SEP-1686 task capabilities declaration."""
22

3-
from importlib.util import find_spec
4-
53
from mcp.types import (
64
ServerTasksCapability,
75
ServerTasksRequestsCapability,
@@ -12,23 +10,27 @@
1210
)
1311

1412

15-
def _is_docket_available() -> bool:
16-
"""Check if pydocket is installed (local to avoid circular import)."""
17-
return find_spec("docket") is not None
18-
19-
2013
def get_task_capabilities() -> ServerTasksCapability | None:
2114
"""Return the SEP-1686 task capabilities.
2215
2316
Returns task capabilities as a first-class ServerCapabilities field,
2417
declaring support for list, cancel, and request operations per SEP-1686.
2518
26-
Returns None if pydocket is not installed (no task support).
19+
Returns None if a compatible pydocket is not installed (no task support).
20+
Uses the canonical ``is_docket_available()`` check so that capability
21+
advertisement and handler registration stay in sync — otherwise a server
22+
with an old transitive pydocket would advertise task support and then
23+
return "method not found" when clients invoked it.
2724
2825
Note: prompts/resources are passed via extra_data since the SDK types
2926
don't include them yet (FastMCP supports them ahead of the spec).
3027
"""
31-
if not _is_docket_available():
28+
# Function-local import to avoid a circular import at module load time:
29+
# fastmcp.server.tasks.__init__ pulls in this module, and dependencies
30+
# transitively reaches back into fastmcp.server.tasks.keys.
31+
from fastmcp.server.dependencies import is_docket_available
32+
33+
if not is_docket_available():
3234
return None
3335

3436
return ServerTasksCapability(

tests/server/tasks/test_task_capabilities.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,28 @@ async def test_tool() -> str:
4242
assert client.initialize_result is not None
4343
# Session should be a ClientSession (task-capable init uses standard session)
4444
assert type(client.session).__name__ == "ClientSession"
45+
46+
47+
def test_capabilities_hidden_when_pydocket_too_old(monkeypatch):
48+
"""Capability advertisement and handler registration must agree.
49+
50+
If ``is_docket_available()`` returns False (e.g. an old transitive
51+
pydocket), the server skips registering task handlers — so it must
52+
also stop advertising task capabilities, or clients would discover
53+
task support and then hit "method not found" at runtime.
54+
"""
55+
import importlib.metadata
56+
57+
from fastmcp.server import dependencies
58+
59+
original_version = importlib.metadata.version
60+
61+
def fake_version(name: str) -> str:
62+
if name == "pydocket":
63+
return "0.16.6"
64+
return original_version(name)
65+
66+
monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None)
67+
monkeypatch.setattr(importlib.metadata, "version", fake_version)
68+
69+
assert get_task_capabilities() is None

tests/server/test_dependencies.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,12 +757,109 @@ def test_is_docket_available(self):
757757

758758
assert is_docket_available() is True
759759

760+
def test_is_docket_available_false_when_pydocket_too_old(self, monkeypatch):
761+
"""``is_docket_available()`` must treat pre-0.18.0 pydocket as unavailable.
762+
763+
Older pydocket versions (e.g. 0.16.x, pulled in transitively by
764+
packages like prefect) import cleanly but lack the APIs fastmcp
765+
uses (``docket.dependencies.current_execution``, etc.). Without a
766+
version floor, the check would report available and then crash at
767+
runtime. Simulate by forcing ``importlib.metadata`` to report an
768+
old version.
769+
"""
770+
import importlib.metadata
771+
772+
from fastmcp.server import dependencies
773+
774+
original_version = importlib.metadata.version
775+
776+
def fake_version(name: str) -> str:
777+
if name == "pydocket":
778+
return "0.16.6"
779+
return original_version(name)
780+
781+
monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None)
782+
monkeypatch.setattr(importlib.metadata, "version", fake_version)
783+
784+
assert dependencies.is_docket_available() is False
785+
# The wrapper that actually failed in #3803 must now return None
786+
# instead of raising ImportError on the inner import.
787+
assert dependencies.get_task_context() is None
788+
789+
def test_is_docket_available_false_when_pydocket_not_installed(self, monkeypatch):
790+
"""``is_docket_available()`` returns False when pydocket is absent."""
791+
import importlib.metadata
792+
793+
from fastmcp.server import dependencies
794+
795+
original_version = importlib.metadata.version
796+
797+
def fake_version(name: str) -> str:
798+
if name == "pydocket":
799+
raise importlib.metadata.PackageNotFoundError(name)
800+
return original_version(name)
801+
802+
monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None)
803+
monkeypatch.setattr(importlib.metadata, "version", fake_version)
804+
805+
assert dependencies.is_docket_available() is False
806+
807+
def test_is_docket_available_false_when_import_broken(self, monkeypatch):
808+
"""Metadata says installed but ``import docket`` fails — treat as unavailable.
809+
810+
Catches the broken/partial-install case where ``importlib.metadata``
811+
still reports a usable version but the package itself isn't actually
812+
importable (corrupted wheel, sys.path weirdness, etc.). Without the
813+
import probe, fastmcp would later crash on its first ``from docket
814+
...`` instead of falling back gracefully.
815+
"""
816+
import builtins
817+
818+
from fastmcp.server import dependencies
819+
820+
original_import = builtins.__import__
821+
822+
def fake_import(name, *args, **kwargs):
823+
if name == "docket" or name.startswith("docket."):
824+
raise ImportError("simulated broken docket install")
825+
return original_import(name, *args, **kwargs)
826+
827+
monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None)
828+
monkeypatch.setattr(builtins, "__import__", fake_import)
829+
830+
assert dependencies.is_docket_available() is False
831+
760832
def test_require_docket_passes_when_installed(self):
761833
"""Test require_docket doesn't raise when docket is installed."""
762834
from fastmcp.server.dependencies import require_docket
763835

764836
require_docket("test feature")
765837

838+
def test_require_docket_error_mentions_version_when_too_old(self, monkeypatch):
839+
"""``require_docket()`` distinguishes "missing" from "too old".
840+
841+
When pydocket is installed but pinned below the floor, the install
842+
instructions in the error must point at upgrading pydocket — not at
843+
installing the ``tasks`` extra (which the resolver will treat as a
844+
no-op as long as the lower pin is held by another package).
845+
"""
846+
import importlib.metadata
847+
848+
from fastmcp.server import dependencies
849+
850+
original_version = importlib.metadata.version
851+
852+
def fake_version(name: str) -> str:
853+
if name == "pydocket":
854+
return "0.16.6"
855+
return original_version(name)
856+
857+
monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None)
858+
monkeypatch.setattr(importlib.metadata, "version", fake_version)
859+
860+
with pytest.raises(ImportError, match="pydocket 0.16.6 is installed"):
861+
dependencies.require_docket("CurrentDocket()")
862+
766863
def test_dependency_class_exists(self):
767864
"""Test Dependency and Depends are importable from fastmcp."""
768865
from fastmcp.dependencies import Dependency, Depends

0 commit comments

Comments
 (0)