Version-check is_docket_available() to avoid transitive pydocket crash (#3807)

This commit is contained in:
Jeremiah Lowin 2026-04-09 21:52:21 -04:00 committed by GitHub
commit faf5f86e09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 178 additions and 18 deletions

View file

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

View file

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

View file

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

View file

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