mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
Version-check is_docket_available() to avoid transitive pydocket crash (#3807)
This commit is contained in:
parent
ce9c4bcd53
commit
faf5f86e09
4 changed files with 178 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue