mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 20:44:17 +02:00
Remove enable_docket setting; Docket is now always on
Docket provides background task execution and is now always available for all FastMCP servers. Only `enable_tasks` remains to control the SEP-1686 task protocol support. Changes: - Remove `enable_docket` setting and related validation - Docket/Worker lifecycle is always active in server lifespan - CurrentDocket and CurrentWorker dependencies work without config - Add server readiness signaling via `_started` event - Fix test timing issues with proper port probing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e47abb4bd7
commit
47d3044b06
21 changed files with 150 additions and 303 deletions
|
|
@ -55,11 +55,8 @@ Background tasks require explicit opt-in:
|
|||
| Environment Variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `FASTMCP_ENABLE_TASKS` | `false` | Enable the MCP task protocol |
|
||||
| `FASTMCP_ENABLE_DOCKET` | `false` | Enable the Docket task system |
|
||||
| `FASTMCP_DOCKET_URL` | `memory://` | Backend URL (`memory://` or `redis://host:port/db`) |
|
||||
|
||||
Both `ENABLE_TASKS` and `ENABLE_DOCKET` must be `true` for background tasks to work.
|
||||
|
||||
You can also set a server-wide default in the constructor:
|
||||
|
||||
```python
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@
|
|||
# This file is loaded by direnv (https://direnv.net/) when you cd into this directory
|
||||
# Run `direnv allow` to enable automatic environment loading
|
||||
|
||||
# Enable Docket support for background task execution
|
||||
export FASTMCP_ENABLE_DOCKET=true
|
||||
|
||||
# Enable MCP SEP-1686 task protocol support
|
||||
export FASTMCP_ENABLE_TASKS=true
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ fastmcp tasks worker server.py
|
|||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `FASTMCP_ENABLE_DOCKET` | `false` | Enable Docket task system |
|
||||
| `FASTMCP_ENABLE_TASKS` | `false` | Enable MCP task protocol (SEP-1686) |
|
||||
| `FASTMCP_DOCKET_URL` | `memory://` | Docket backend URL |
|
||||
|
||||
|
|
|
|||
|
|
@ -19,25 +19,17 @@ tasks_app = cyclopts.App(
|
|||
)
|
||||
|
||||
|
||||
def check_docket_enabled() -> None:
|
||||
"""Check if Docket is enabled with a distributed backend.
|
||||
def check_distributed_backend() -> None:
|
||||
"""Check if Docket is configured with a distributed backend.
|
||||
|
||||
The CLI worker runs as a separate process, so it needs Redis/Valkey
|
||||
to coordinate with the main server process.
|
||||
|
||||
Raises:
|
||||
SystemExit: If Docket isn't enabled or using memory:// URL
|
||||
SystemExit: If using memory:// URL
|
||||
"""
|
||||
import fastmcp
|
||||
|
||||
# Check if Docket is enabled
|
||||
if not fastmcp.settings.enable_docket:
|
||||
console.print(
|
||||
"[bold red]✗ Docket not enabled[/bold red]\n\n"
|
||||
"Docket task support is not enabled.\n\n"
|
||||
"To enable Docket, set the environment variable:\n"
|
||||
" [cyan]export FASTMCP_ENABLE_DOCKET=true[/cyan]\n\n"
|
||||
"Then try again."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
docket_url = fastmcp.settings.docket.url
|
||||
|
||||
# Check for memory:// URL and provide helpful error
|
||||
|
|
@ -86,7 +78,7 @@ def worker(
|
|||
"""
|
||||
import fastmcp
|
||||
|
||||
check_docket_enabled()
|
||||
check_distributed_backend()
|
||||
|
||||
# Load server to get task functions
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -859,7 +859,7 @@ class FastMCPTransport(ClientTransport):
|
|||
|
||||
experimental_capabilities = {}
|
||||
if fastmcp.settings.enable_tasks:
|
||||
# Declare SEP-1686 task support (enable_tasks requires enable_docket via validator)
|
||||
# Declare SEP-1686 task support
|
||||
experimental_capabilities["tasks"] = {
|
||||
"tools": True,
|
||||
"prompts": True,
|
||||
|
|
|
|||
|
|
@ -297,21 +297,12 @@ class _CurrentDocket(Dependency):
|
|||
"""Internal dependency class for CurrentDocket."""
|
||||
|
||||
async def __aenter__(self) -> Docket:
|
||||
import fastmcp
|
||||
|
||||
# Check if flag is enabled
|
||||
if not fastmcp.settings.enable_docket:
|
||||
raise RuntimeError(
|
||||
"Docket support is not enabled. "
|
||||
"Set FASTMCP_ENABLE_DOCKET=true to enable Docket support."
|
||||
)
|
||||
|
||||
# Get Docket from ContextVar (set by _docket_lifespan)
|
||||
docket = _current_docket.get()
|
||||
if docket is None:
|
||||
raise RuntimeError(
|
||||
"No Docket instance found. This should not happen when "
|
||||
"FASTMCP_ENABLE_DOCKET is enabled."
|
||||
"No Docket instance found. Docket is only available within "
|
||||
"a running FastMCP server context."
|
||||
)
|
||||
|
||||
return docket
|
||||
|
|
@ -321,16 +312,13 @@ def CurrentDocket() -> Docket:
|
|||
"""Get the current Docket instance managed by FastMCP.
|
||||
|
||||
This dependency provides access to the Docket instance that FastMCP
|
||||
automatically creates when Docket support is enabled.
|
||||
|
||||
Requires:
|
||||
- FASTMCP_ENABLE_DOCKET=true
|
||||
automatically creates for background task scheduling.
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to the active Docket instance
|
||||
|
||||
Raises:
|
||||
RuntimeError: If flag not enabled (during resolution)
|
||||
RuntimeError: If not within a FastMCP server context
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -349,19 +337,11 @@ class _CurrentWorker(Dependency):
|
|||
"""Internal dependency class for CurrentWorker."""
|
||||
|
||||
async def __aenter__(self) -> Worker:
|
||||
import fastmcp
|
||||
|
||||
if not fastmcp.settings.enable_docket:
|
||||
raise RuntimeError(
|
||||
"Docket support is not enabled. "
|
||||
"Set FASTMCP_ENABLE_DOCKET=true to enable Docket support."
|
||||
)
|
||||
|
||||
worker = _current_worker.get()
|
||||
if worker is None:
|
||||
raise RuntimeError(
|
||||
"No Worker instance found. This should not happen when "
|
||||
"FASTMCP_ENABLE_DOCKET is enabled."
|
||||
"No Worker instance found. Worker is only available within "
|
||||
"a running FastMCP server context."
|
||||
)
|
||||
|
||||
return worker
|
||||
|
|
@ -371,16 +351,13 @@ def CurrentWorker() -> Worker:
|
|||
"""Get the current Docket Worker instance managed by FastMCP.
|
||||
|
||||
This dependency provides access to the Worker instance that FastMCP
|
||||
automatically creates when Docket support is enabled.
|
||||
|
||||
Requires:
|
||||
- FASTMCP_ENABLE_DOCKET=true
|
||||
automatically creates for background task processing.
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to the active Worker instance
|
||||
|
||||
Raises:
|
||||
RuntimeError: If flag not enabled (during resolution)
|
||||
RuntimeError: If not within a FastMCP server context
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -463,8 +440,7 @@ class Progress(DocketProgress):
|
|||
docket = _current_docket.get()
|
||||
if docket is None:
|
||||
raise RuntimeError(
|
||||
"Progress dependency requires Docket to be enabled. "
|
||||
"Set FASTMCP_ENABLE_DOCKET=true"
|
||||
"Progress dependency requires a FastMCP server context."
|
||||
) from None
|
||||
|
||||
# Return in-memory progress for immediate execution
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from contextlib import (
|
|||
AbstractAsyncContextManager,
|
||||
AsyncExitStack,
|
||||
asynccontextmanager,
|
||||
suppress,
|
||||
)
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
|
|
@ -213,6 +214,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
self._lifespan: LifespanCallable[LifespanResultT] = lifespan or default_lifespan
|
||||
self._lifespan_result: LifespanResultT | None = None
|
||||
self._lifespan_result_set: bool = False
|
||||
self._started: asyncio.Event = asyncio.Event()
|
||||
|
||||
# Generate random ID if no name provided
|
||||
self._mcp_server: LowLevelServer[LifespanResultT, Any] = LowLevelServer[
|
||||
|
|
@ -374,34 +376,16 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
return self._docket
|
||||
|
||||
@asynccontextmanager
|
||||
async def _docket_lifespan(
|
||||
self, user_lifespan_result: LifespanResultT
|
||||
) -> AsyncIterator[LifespanResultT]:
|
||||
"""Manage Docket instance and Worker when experimental support is enabled.
|
||||
|
||||
Args:
|
||||
user_lifespan_result: The result from the user's lifespan function
|
||||
|
||||
Yields:
|
||||
User's lifespan result (Docket is managed via ContextVar, not lifespan result)
|
||||
"""
|
||||
async def _docket_lifespan(self) -> AsyncIterator[None]:
|
||||
"""Manage Docket instance and Worker for background task execution."""
|
||||
from fastmcp import settings
|
||||
from fastmcp.server.dependencies import _current_docket, _current_worker
|
||||
|
||||
# Validate configuration
|
||||
if settings.enable_tasks and not settings.enable_docket:
|
||||
raise RuntimeError(
|
||||
"Server requires enable_docket=True when enable_tasks=True. "
|
||||
"Task protocol support needs Docket for background execution."
|
||||
)
|
||||
|
||||
if not settings.enable_docket:
|
||||
# Docket support not enabled, pass through user lifespan result
|
||||
yield user_lifespan_result
|
||||
return
|
||||
|
||||
# Set FastMCP server in ContextVar so CurrentFastMCP can access it (use weakref to avoid reference cycles)
|
||||
from fastmcp.server.dependencies import _current_server
|
||||
from fastmcp.server.dependencies import (
|
||||
_current_docket,
|
||||
_current_server,
|
||||
_current_worker,
|
||||
)
|
||||
|
||||
server_token = _current_server.set(weakref.ref(self))
|
||||
|
||||
|
|
@ -414,9 +398,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
# Store on server instance for cross-task access (FastMCPTransport)
|
||||
self._docket = docket
|
||||
|
||||
# Register task-enabled tools/prompts/resources with Docket
|
||||
tools = await self.get_tools()
|
||||
for tool in tools.values():
|
||||
# Register local task-enabled tools/prompts/resources with Docket
|
||||
for tool in self._tool_manager._tools.values():
|
||||
if not hasattr(tool, "fn"):
|
||||
continue
|
||||
supports_task = (
|
||||
tool.task
|
||||
if tool.task is not None
|
||||
|
|
@ -425,8 +410,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
if supports_task:
|
||||
docket.register(tool.fn)
|
||||
|
||||
prompts = await self.get_prompts()
|
||||
for prompt in prompts.values():
|
||||
for prompt in self._prompt_manager._prompts.values():
|
||||
if not hasattr(prompt, "fn"):
|
||||
continue
|
||||
supports_task = (
|
||||
prompt.task
|
||||
if prompt.task is not None
|
||||
|
|
@ -435,8 +421,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
if supports_task:
|
||||
docket.register(prompt.fn)
|
||||
|
||||
resources = await self.get_resources()
|
||||
for resource in resources.values():
|
||||
for resource in self._resource_manager._resources.values():
|
||||
if not hasattr(resource, "fn"):
|
||||
continue
|
||||
supports_task = (
|
||||
resource.task
|
||||
if resource.task is not None
|
||||
|
|
@ -445,6 +432,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
if supports_task:
|
||||
docket.register(resource.fn)
|
||||
|
||||
for template in self._resource_manager._templates.values():
|
||||
if not hasattr(template, "fn"):
|
||||
continue
|
||||
supports_task = (
|
||||
template.task
|
||||
if template.task is not None
|
||||
else self._support_tasks_by_default
|
||||
)
|
||||
if supports_task:
|
||||
docket.register(template.fn)
|
||||
|
||||
# Set Docket in ContextVar so CurrentDocket can access it
|
||||
docket_token = _current_docket.set(docket)
|
||||
try:
|
||||
|
|
@ -457,22 +455,19 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
if settings.docket.worker_name:
|
||||
worker_kwargs["name"] = settings.docket.worker_name
|
||||
|
||||
# Create and start Worker, then task group for run_forever()
|
||||
async with (
|
||||
Worker(docket, **worker_kwargs) as worker, # type: ignore[arg-type]
|
||||
anyio.create_task_group() as tg,
|
||||
):
|
||||
# Create and start Worker
|
||||
async with Worker(docket, **worker_kwargs) as worker: # type: ignore[arg-type]
|
||||
# Set Worker in ContextVar so CurrentWorker can access it
|
||||
worker_token = _current_worker.set(worker)
|
||||
try:
|
||||
# Start worker as background task
|
||||
tg.start_soon(worker.run_forever)
|
||||
|
||||
worker_task = asyncio.create_task(worker.run_forever())
|
||||
try:
|
||||
yield user_lifespan_result
|
||||
yield
|
||||
finally:
|
||||
# Cancel task group when exiting (cancels worker)
|
||||
tg.cancel_scope.cancel()
|
||||
# Cancel worker task on exit
|
||||
worker_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await worker_task
|
||||
finally:
|
||||
_current_worker.reset(worker_token)
|
||||
finally:
|
||||
|
|
@ -492,9 +487,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
async with (
|
||||
self._lifespan(self) as user_lifespan_result,
|
||||
self._docket_lifespan(user_lifespan_result) as lifespan_result,
|
||||
self._docket_lifespan(),
|
||||
):
|
||||
self._lifespan_result = lifespan_result
|
||||
self._lifespan_result = user_lifespan_result
|
||||
self._lifespan_result_set = True
|
||||
|
||||
async with AsyncExitStack[bool | None]() as stack:
|
||||
|
|
@ -503,7 +498,11 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
cm=server.server._lifespan_manager()
|
||||
)
|
||||
|
||||
yield
|
||||
self._started.set()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._started.clear()
|
||||
|
||||
self._lifespan_result_set = False
|
||||
self._lifespan_result = None
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ async def handle_tool_as_task(
|
|||
raise McpError(
|
||||
ErrorData(
|
||||
code=INTERNAL_ERROR,
|
||||
message="Background tasks require Docket. Set FASTMCP_ENABLE_DOCKET=true",
|
||||
message="Background tasks require a running FastMCP server context",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -169,7 +169,7 @@ async def handle_prompt_as_task(
|
|||
raise McpError(
|
||||
ErrorData(
|
||||
code=INTERNAL_ERROR,
|
||||
message="Background tasks require Docket. Set FASTMCP_ENABLE_DOCKET=true",
|
||||
message="Background tasks require a running FastMCP server context",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -209,21 +209,7 @@ class Settings(BaseSettings):
|
|||
|
||||
experimental: ExperimentalSettings = ExperimentalSettings()
|
||||
|
||||
# Docket/Tasks settings
|
||||
enable_docket: Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Enable Docket support for background task execution.
|
||||
When enabled, FastMCP will create a Docket instance with a Worker
|
||||
available via dependency injection. This allows tools, prompts, and
|
||||
resources to schedule background work using CurrentDocket().
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = False
|
||||
|
||||
# Tasks settings
|
||||
enable_tasks: Annotated[
|
||||
bool,
|
||||
Field(
|
||||
|
|
@ -231,11 +217,11 @@ class Settings(BaseSettings):
|
|||
"""
|
||||
Enable MCP SEP-1686 task protocol support for background execution.
|
||||
|
||||
Server-side: Requires enable_docket=True (validated at server startup).
|
||||
Advertises task capabilities and handles task/* protocol methods.
|
||||
Server-side: Advertises task capabilities and handles task/* protocol
|
||||
methods. Tools, prompts, and resources marked with task=True will
|
||||
execute in the background via Docket.
|
||||
|
||||
Client-side: Advertises task capability to servers. No Docket needed
|
||||
on client side.
|
||||
Client-side: Advertises task capability to servers.
|
||||
"""
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,36 @@ if TYPE_CHECKING:
|
|||
from fastmcp.server.server import FastMCP
|
||||
|
||||
|
||||
async def _wait_for_port(
|
||||
host: str, port: int, timeout: float = 5.0, interval: float = 0.01
|
||||
) -> None:
|
||||
"""Wait for a port to accept connections.
|
||||
|
||||
Args:
|
||||
host: Host to connect to
|
||||
port: Port to probe
|
||||
timeout: Maximum time to wait in seconds
|
||||
interval: Time between connection attempts
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
start = asyncio.get_running_loop().time()
|
||||
while True:
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port), timeout=interval
|
||||
)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return
|
||||
except (OSError, asyncio.TimeoutError):
|
||||
if asyncio.get_running_loop().time() - start > timeout:
|
||||
raise TimeoutError(
|
||||
f"Port {port} on {host} not available after {timeout}s"
|
||||
) from None
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def temporary_settings(**kwargs: Any):
|
||||
"""
|
||||
|
|
@ -209,16 +239,20 @@ async def run_server_async(
|
|||
)
|
||||
)
|
||||
|
||||
# Give the server a moment to start
|
||||
await asyncio.sleep(0.1)
|
||||
# Wait for server lifespan to be ready
|
||||
await server._started.wait()
|
||||
|
||||
# Wait for HTTP port to be listening (lifespan is ready but uvicorn
|
||||
# may not have bound yet)
|
||||
await _wait_for_port(host, port)
|
||||
|
||||
try:
|
||||
yield f"http://{host}:{port}{path}"
|
||||
finally:
|
||||
# Cleanup: cancel the task
|
||||
# Cleanup: cancel the task with timeout to avoid hanging on Windows
|
||||
server_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await server_task
|
||||
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
|
||||
await asyncio.wait_for(server_task, timeout=2.0)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
|
|||
|
|
@ -2,38 +2,23 @@
|
|||
|
||||
import pytest
|
||||
|
||||
from fastmcp.cli.tasks import check_docket_enabled, tasks_app
|
||||
from fastmcp.cli.tasks import check_distributed_backend, tasks_app
|
||||
from fastmcp.utilities.tests import temporary_settings
|
||||
|
||||
|
||||
class TestCheckDocketEnabled:
|
||||
"""Test the Docket enabled checker function."""
|
||||
class TestCheckDistributedBackend:
|
||||
"""Test the distributed backend checker function."""
|
||||
|
||||
def test_succeeds_when_docket_enabled_with_redis(self):
|
||||
"""Test that it succeeds when Docket is enabled with Redis."""
|
||||
with temporary_settings(
|
||||
enable_docket=True,
|
||||
docket__url="redis://localhost:6379/0",
|
||||
):
|
||||
check_docket_enabled()
|
||||
|
||||
def test_exits_when_docket_not_enabled(self):
|
||||
"""Test that it exits with helpful error when Docket not enabled."""
|
||||
with temporary_settings(enable_docket=False):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
check_docket_enabled()
|
||||
|
||||
assert isinstance(exc_info.value, SystemExit)
|
||||
assert exc_info.value.code == 1
|
||||
def test_succeeds_with_redis_url(self):
|
||||
"""Test that it succeeds with Redis URL."""
|
||||
with temporary_settings(docket__url="redis://localhost:6379/0"):
|
||||
check_distributed_backend()
|
||||
|
||||
def test_exits_with_helpful_error_for_memory_url(self):
|
||||
"""Test that it exits with helpful error for memory:// URLs."""
|
||||
with temporary_settings(
|
||||
enable_docket=True,
|
||||
docket__url="memory://test-123",
|
||||
):
|
||||
with temporary_settings(docket__url="memory://test-123"):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
check_docket_enabled()
|
||||
check_distributed_backend()
|
||||
|
||||
assert isinstance(exc_info.value, SystemExit)
|
||||
assert exc_info.value.code == 1
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@ from fastmcp.utilities.tests import temporary_settings
|
|||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def enable_docket_and_tasks():
|
||||
"""Enable Docket and task protocol support for all client task tests."""
|
||||
with temporary_settings(
|
||||
enable_docket=True,
|
||||
enable_tasks=True,
|
||||
):
|
||||
async def enable_tasks():
|
||||
"""Enable task protocol support for all client task tests."""
|
||||
with temporary_settings(enable_tasks=True):
|
||||
yield
|
||||
|
|
|
|||
|
|
@ -58,8 +58,7 @@ async def stateless_server(stateful_proxy_server: FastMCP):
|
|||
host="127.0.0.1", port=port, stateless_http=True
|
||||
)
|
||||
)
|
||||
async with Client(transport=url) as client:
|
||||
assert await client.ping()
|
||||
await stateful_proxy_server._started.wait()
|
||||
yield url
|
||||
task.cancel()
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -6,16 +6,12 @@ from fastmcp.utilities.tests import temporary_settings
|
|||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def enable_docket_and_tasks():
|
||||
"""Enable Docket and task protocol support for all task tests."""
|
||||
with temporary_settings(
|
||||
enable_docket=True,
|
||||
enable_tasks=True,
|
||||
):
|
||||
# Verify both are enabled
|
||||
async def enable_tasks():
|
||||
"""Enable task protocol support for all task tests."""
|
||||
with temporary_settings(enable_tasks=True):
|
||||
# Verify enabled
|
||||
import fastmcp
|
||||
|
||||
assert fastmcp.settings.enable_docket, "Docket should be enabled after fixture"
|
||||
assert fastmcp.settings.enable_tasks, "Tasks should be enabled after fixture"
|
||||
|
||||
yield
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
"""Tests for FastMCP Progress dependency."""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.dependencies import Progress
|
||||
from fastmcp.utilities.tests import temporary_settings
|
||||
|
||||
|
||||
async def test_progress_in_immediate_execution():
|
||||
|
|
@ -111,24 +108,6 @@ async def test_progress_status_message_in_background_task():
|
|||
assert result.content[0].text == "done"
|
||||
|
||||
|
||||
async def test_progress_fails_without_docket():
|
||||
"""Test Progress dependency fails when Docket is not enabled."""
|
||||
with temporary_settings(enable_docket=False, enable_tasks=False):
|
||||
mcp = FastMCP("test")
|
||||
|
||||
@mcp.tool()
|
||||
async def test_tool(progress: Progress = Progress()) -> str:
|
||||
return "done"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.call_tool("test_tool", {})
|
||||
|
||||
error_str = str(exc_info.value)
|
||||
assert "Failed to resolve dependency" in error_str
|
||||
assert "progress" in error_str
|
||||
|
||||
|
||||
async def test_inmemory_progress_state():
|
||||
"""Test that in-memory progress stores and returns state correctly."""
|
||||
mcp = FastMCP("test")
|
||||
|
|
|
|||
|
|
@ -81,10 +81,7 @@ async def test_server_tasks_false_defaults_all_components():
|
|||
async def test_server_tasks_none_uses_settings():
|
||||
"""Server with tasks=None (or omitted) uses global settings."""
|
||||
# Test with enable_tasks=True in settings
|
||||
with temporary_settings(
|
||||
enable_docket=True,
|
||||
enable_tasks=True,
|
||||
):
|
||||
with temporary_settings(enable_tasks=True):
|
||||
mcp = FastMCP("test") # tasks=None, should use settings
|
||||
|
||||
@mcp.tool()
|
||||
|
|
@ -97,10 +94,7 @@ async def test_server_tasks_none_uses_settings():
|
|||
assert not tool_task.returned_immediately
|
||||
|
||||
# Test with enable_tasks=False in settings
|
||||
with temporary_settings(
|
||||
enable_docket=True,
|
||||
enable_tasks=False,
|
||||
):
|
||||
with temporary_settings(enable_tasks=False):
|
||||
mcp2 = FastMCP("test2") # tasks=None, should use settings
|
||||
|
||||
@mcp2.tool()
|
||||
|
|
@ -249,10 +243,7 @@ async def test_mixed_explicit_and_inherited():
|
|||
async def test_server_tasks_parameter_sets_component_defaults():
|
||||
"""Server tasks parameter sets component defaults but global settings gate protocol."""
|
||||
# Server tasks=True sets component defaults, but enable_tasks must be True
|
||||
with temporary_settings(
|
||||
enable_docket=True,
|
||||
enable_tasks=True,
|
||||
):
|
||||
with temporary_settings(enable_tasks=True):
|
||||
mcp = FastMCP("test", tasks=True)
|
||||
|
||||
@mcp.tool()
|
||||
|
|
@ -265,10 +256,7 @@ async def test_server_tasks_parameter_sets_component_defaults():
|
|||
assert not tool_task.returned_immediately
|
||||
|
||||
# Server tasks=False sets component defaults
|
||||
with temporary_settings(
|
||||
enable_docket=True,
|
||||
enable_tasks=True,
|
||||
):
|
||||
with temporary_settings(enable_tasks=True):
|
||||
mcp2 = FastMCP("test2", tasks=False)
|
||||
|
||||
@mcp2.tool()
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ Tests for SEP-1686 task capabilities declaration.
|
|||
Verifies that the server correctly advertises task support based on settings.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.utilities.tests import temporary_settings
|
||||
|
|
@ -13,10 +11,7 @@ from fastmcp.utilities.tests import temporary_settings
|
|||
|
||||
async def test_capabilities_include_tasks_when_enabled():
|
||||
"""Server capabilities include tasks when enable_tasks=True."""
|
||||
with temporary_settings(
|
||||
enable_docket=True,
|
||||
enable_tasks=True,
|
||||
):
|
||||
with temporary_settings(enable_tasks=True):
|
||||
mcp = FastMCP("capability-test")
|
||||
|
||||
@mcp.tool()
|
||||
|
|
@ -40,10 +35,7 @@ async def test_capabilities_include_tasks_when_enabled():
|
|||
|
||||
async def test_capabilities_exclude_tasks_when_disabled():
|
||||
"""Server capabilities do NOT include tasks when enable_tasks=False."""
|
||||
with temporary_settings(
|
||||
enable_docket=True,
|
||||
enable_tasks=False,
|
||||
):
|
||||
with temporary_settings(enable_tasks=False):
|
||||
mcp = FastMCP("capability-test")
|
||||
|
||||
@mcp.tool()
|
||||
|
|
@ -59,51 +51,9 @@ async def test_capabilities_exclude_tasks_when_disabled():
|
|||
assert "tasks" not in init_result.capabilities.experimental
|
||||
|
||||
|
||||
async def test_capabilities_exclude_tasks_when_docket_disabled():
|
||||
"""Server capabilities do NOT include tasks when enable_docket=False."""
|
||||
with temporary_settings(
|
||||
enable_docket=False,
|
||||
enable_tasks=False,
|
||||
):
|
||||
mcp = FastMCP("capability-test")
|
||||
|
||||
@mcp.tool()
|
||||
def test_tool() -> str:
|
||||
return "test"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Get server initialization result
|
||||
init_result = client.initialize_result
|
||||
|
||||
# Verify tasks capability is NOT present
|
||||
if init_result.capabilities.experimental:
|
||||
assert "tasks" not in init_result.capabilities.experimental
|
||||
|
||||
|
||||
async def test_enable_tasks_requires_enable_docket():
|
||||
"""Setting enable_tasks=True without enable_docket=True raises error at server startup."""
|
||||
with temporary_settings(
|
||||
enable_docket=False,
|
||||
enable_tasks=True,
|
||||
):
|
||||
mcp = FastMCP("config-test")
|
||||
|
||||
@mcp.tool()
|
||||
async def test_tool() -> str:
|
||||
return "test"
|
||||
|
||||
# Should fail when trying to start server (during lifespan)
|
||||
with pytest.raises(RuntimeError, match="requires.*enable_docket.*enable_tasks"):
|
||||
async with Client(mcp):
|
||||
pass # Should never reach here
|
||||
|
||||
|
||||
async def test_client_advertises_task_capability_when_enabled():
|
||||
"""Client advertises experimental.tasks capability when enable_tasks=True."""
|
||||
with temporary_settings(
|
||||
enable_docket=True,
|
||||
enable_tasks=True,
|
||||
):
|
||||
with temporary_settings(enable_tasks=True):
|
||||
mcp = FastMCP("client-cap-test")
|
||||
|
||||
@mcp.tool()
|
||||
|
|
@ -117,10 +67,7 @@ async def test_client_advertises_task_capability_when_enabled():
|
|||
|
||||
async def test_client_does_not_advertise_tasks_when_disabled():
|
||||
"""Client does NOT use custom session when enable_tasks=False."""
|
||||
with temporary_settings(
|
||||
enable_docket=True,
|
||||
enable_tasks=False,
|
||||
):
|
||||
with temporary_settings(enable_tasks=False):
|
||||
mcp = FastMCP("no-tasks-client-test")
|
||||
|
||||
@mcp.tool()
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ async def test_uvicorn_logging_default_level(
|
|||
server_task = asyncio.create_task(
|
||||
mcp_server.run_http_async(log_level=test_log_level, port=8003)
|
||||
)
|
||||
await asyncio.sleep(0.01)
|
||||
await mcp_server._started.wait()
|
||||
|
||||
mock_uvicorn_config_constructor.assert_called_once()
|
||||
_, kwargs_config = mock_uvicorn_config_constructor.call_args
|
||||
|
|
@ -96,7 +96,7 @@ async def test_uvicorn_logging_with_custom_log_config(
|
|||
uvicorn_config={"log_config": sample_log_config}, port=8004
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.01)
|
||||
await mcp_server._started.wait()
|
||||
|
||||
mock_uvicorn_config_constructor.assert_called_once()
|
||||
_, kwargs_config = mock_uvicorn_config_constructor.call_args
|
||||
|
|
@ -159,7 +159,7 @@ async def test_uvicorn_logging_custom_log_config_overrides_log_level_param(
|
|||
port=8005,
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.01)
|
||||
await mcp_server._started.wait()
|
||||
|
||||
mock_uvicorn_config_constructor.assert_called_once()
|
||||
_, kwargs_config = mock_uvicorn_config_constructor.call_args
|
||||
|
|
|
|||
|
|
@ -950,10 +950,8 @@ class TestAsProxyKwarg:
|
|||
async with Client(mcp) as client:
|
||||
await client.call_tool("hello", {})
|
||||
|
||||
assert len(lifespan_check) > 0
|
||||
# in the present implementation the sub server will be invoked 3 times
|
||||
# to call its tool
|
||||
assert lifespan_check.count("start") >= 2
|
||||
# Lifespan is entered exactly once and kept alive by Docket worker
|
||||
assert lifespan_check == ["start"]
|
||||
|
||||
|
||||
class TestResourceNamePrefixing:
|
||||
|
|
|
|||
|
|
@ -3,43 +3,19 @@
|
|||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
from docket import Docket
|
||||
from docket.worker import Worker
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.dependencies import CurrentDocket, CurrentWorker
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.server.dependencies import get_context
|
||||
from fastmcp.utilities.tests import temporary_settings
|
||||
|
||||
HUZZAH = "huzzah!"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_docket():
|
||||
"""Enable Docket support for all tests in this suite."""
|
||||
with temporary_settings(enable_docket=True):
|
||||
yield
|
||||
|
||||
|
||||
async def test_docket_disabled():
|
||||
"""Verify that Docket errors when flag is disabled."""
|
||||
with temporary_settings(enable_docket=False):
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
@mcp.tool()
|
||||
def needs_docket(docket: Docket = CurrentDocket()) -> str:
|
||||
return f"Got docket: {type(docket).__name__}"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(ToolError, match="Failed to resolve dependency"):
|
||||
await client.call_tool("needs_docket", {})
|
||||
|
||||
|
||||
async def test_current_docket_with_flag_enabled():
|
||||
"""CurrentDocket dependency works when experimental flag is enabled."""
|
||||
async def test_current_docket():
|
||||
"""CurrentDocket dependency provides access to Docket instance."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
@mcp.tool()
|
||||
|
|
@ -52,8 +28,8 @@ async def test_current_docket_with_flag_enabled():
|
|||
assert HUZZAH in str(result)
|
||||
|
||||
|
||||
async def test_current_worker_with_flag_enabled():
|
||||
"""CurrentWorker dependency works when experimental flag is enabled."""
|
||||
async def test_current_worker():
|
||||
"""CurrentWorker dependency provides access to Worker instance."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
@mcp.tool()
|
||||
|
|
@ -100,7 +76,7 @@ async def test_worker_executes_background_tasks():
|
|||
|
||||
|
||||
async def test_current_docket_in_resource():
|
||||
"""CurrentDocket works in resources when flag is enabled."""
|
||||
"""CurrentDocket works in resources."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
@mcp.resource("docket://info")
|
||||
|
|
@ -114,7 +90,7 @@ async def test_current_docket_in_resource():
|
|||
|
||||
|
||||
async def test_current_docket_in_prompt():
|
||||
"""CurrentDocket works in prompts when flag is enabled."""
|
||||
"""CurrentDocket works in prompts."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
@mcp.prompt()
|
||||
|
|
@ -128,7 +104,7 @@ async def test_current_docket_in_prompt():
|
|||
|
||||
|
||||
async def test_current_docket_in_resource_template():
|
||||
"""CurrentDocket works in resource templates when flag is enabled."""
|
||||
"""CurrentDocket works in resource templates."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
@mcp.resource("docket://tasks/{task_id}")
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ class TestServerLifespan:
|
|||
|
||||
@asynccontextmanager
|
||||
async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]:
|
||||
_ = lifespan_events.append("enter")
|
||||
yield {"initialized": True}
|
||||
_ = lifespan_events.append("exit")
|
||||
lifespan_events.append("enter")
|
||||
try:
|
||||
yield {"initialized": True}
|
||||
finally:
|
||||
lifespan_events.append("exit")
|
||||
|
||||
mcp = FastMCP("TestServer", lifespan=server_lifespan)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue