fastmcp/tests/server/test_server_docket.py
Chris Guidry 66aaf420c9
[2.14] SEP-1686 tasks (#2378)
* Implement MCP background tasks (SEP-1686) using Docket

Adds support for background task execution via the MCP task protocol,
powered by Docket for task queue management.

- Tools, resources, and prompts can be marked with `task=True` to run async
- Progress dependency for tracking task progress
- CurrentDocket and CurrentWorker dependencies for advanced use cases
- Client API with `.call_tool(..., task=True)` returns task handles
- Task status notifications via subscriptions
- CLI worker command for distributed task processing

Configuration via environment:
- FASTMCP_ENABLE_DOCKET=true
- FASTMCP_ENABLE_TASKS=true
- FASTMCP_DOCKET_URL=redis://... (or memory:// for single-process)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix tasks example import (TaskStatusResponse → GetTaskResult)

The example was using a non-existent TaskStatusResponse type.
Updated to use mcp.types.GetTaskResult which is what the
on_status_change callback actually receives.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix env var name in Docket error messages

The error messages referenced FASTMCP_EXPERIMENTAL_ENABLE_DOCKET but the
actual setting is FASTMCP_ENABLE_DOCKET.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove deprecated code re-added from pre-#2329 branch

- Remove ExtendedEnvSettingsSource (FASTMCP_SERVER_ prefix support)
- Remove dependencies parameter from FastMCP.__init__

* Replace fakeredis git pin with PyPI release

* Remove redundant fakeredis dev dep (pulled via pydocket)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
2025-12-04 20:10:35 -05:00

193 lines
6.2 KiB
Python

"""Tests for Docket integration in FastMCP."""
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."""
mcp = FastMCP("test-server")
@mcp.tool()
def check_docket(docket: Docket = CurrentDocket()) -> str:
assert isinstance(docket, Docket)
return HUZZAH
async with Client(mcp) as client:
result = await client.call_tool("check_docket", {})
assert HUZZAH in str(result)
async def test_current_worker_with_flag_enabled():
"""CurrentWorker dependency works when experimental flag is enabled."""
mcp = FastMCP("test-server")
@mcp.tool()
def check_worker(
worker: Worker = CurrentWorker(),
docket: Docket = CurrentDocket(),
) -> str:
assert isinstance(worker, Worker)
assert worker.docket is docket
return HUZZAH
async with Client(mcp) as client:
result = await client.call_tool("check_worker", {})
assert HUZZAH in str(result)
async def test_worker_executes_background_tasks():
"""Verify that the Docket Worker is running and executes tasks."""
task_completed = asyncio.Event()
mcp = FastMCP("test-server")
@mcp.tool()
async def schedule_work(
task_name: str,
docket: Docket = CurrentDocket(),
) -> str:
"""Schedule a background task."""
async def background_task(name: str):
"""Simple background task that signals completion."""
task_completed.set()
# Schedule the task (Worker running in background will execute it)
await docket.add(background_task)(task_name)
return f"Scheduled {task_name}"
async with Client(mcp) as client:
result = await client.call_tool("schedule_work", {"task_name": "test-task"})
assert "Scheduled test-task" in str(result)
# Wait for background task to execute (max 2 seconds)
await asyncio.wait_for(task_completed.wait(), timeout=2.0)
async def test_current_docket_in_resource():
"""CurrentDocket works in resources when flag is enabled."""
mcp = FastMCP("test-server")
@mcp.resource("docket://info")
def get_docket_info(docket: Docket = CurrentDocket()) -> str:
assert isinstance(docket, Docket)
return HUZZAH
async with Client(mcp) as client:
result = await client.read_resource("docket://info")
assert HUZZAH in str(result)
async def test_current_docket_in_prompt():
"""CurrentDocket works in prompts when flag is enabled."""
mcp = FastMCP("test-server")
@mcp.prompt()
def task_prompt(task_type: str, docket: Docket = CurrentDocket()) -> str:
assert isinstance(docket, Docket)
return HUZZAH
async with Client(mcp) as client:
result = await client.get_prompt("task_prompt", {"task_type": "background"})
assert HUZZAH in str(result)
async def test_current_docket_in_resource_template():
"""CurrentDocket works in resource templates when flag is enabled."""
mcp = FastMCP("test-server")
@mcp.resource("docket://tasks/{task_id}")
def get_task_status(task_id: str, docket: Docket = CurrentDocket()) -> str:
assert isinstance(docket, Docket)
return HUZZAH
async with Client(mcp) as client:
result = await client.read_resource("docket://tasks/123")
assert HUZZAH in str(result)
async def test_concurrent_calls_maintain_isolation():
"""Multiple concurrent calls each get the same Docket instance."""
mcp = FastMCP("test-server")
docket_ids = []
@mcp.tool()
def capture_docket_id(call_num: int, docket: Docket = CurrentDocket()) -> str:
docket_ids.append((call_num, id(docket)))
return HUZZAH
async with Client(mcp) as client:
results = await asyncio.gather(
client.call_tool("capture_docket_id", {"call_num": 1}),
client.call_tool("capture_docket_id", {"call_num": 2}),
client.call_tool("capture_docket_id", {"call_num": 3}),
)
for result in results:
assert HUZZAH in str(result)
# All calls should see the same Docket instance
assert len(docket_ids) == 3
first_id = docket_ids[0][1]
assert all(docket_id == first_id for _, docket_id in docket_ids)
async def test_user_lifespan_still_works_with_docket():
"""User-provided lifespan works correctly alongside Docket."""
lifespan_entered = False
@asynccontextmanager
async def custom_lifespan(server: FastMCP):
nonlocal lifespan_entered
lifespan_entered = True
yield {"custom_data": "test_value"}
mcp = FastMCP("test-server", lifespan=custom_lifespan)
@mcp.tool()
def check_both(docket: Docket = CurrentDocket()) -> str:
assert isinstance(docket, Docket)
ctx = get_context()
lifespan_data = ctx.request_context.lifespan_context
assert lifespan_data.get("custom_data") == "test_value"
return HUZZAH
async with Client(mcp) as client:
assert lifespan_entered
result = await client.call_tool("check_both", {})
assert HUZZAH in str(result)