[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>
This commit is contained in:
Chris Guidry 2025-12-04 20:10:35 -05:00 committed by GitHub
commit 66aaf420c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 9959 additions and 459 deletions

View file

@ -0,0 +1,267 @@
"""
Tests that synchronous functions cannot be used as background tasks.
Docket requires async functions for background execution. FastMCP automatically
disables task support for sync functions, with warnings for explicit task=True.
"""
from pytest import LogCaptureFixture
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.utilities.tests import caplog_for_fastmcp
async def test_sync_tool_with_explicit_task_true_warns_and_disables(
caplog: LogCaptureFixture,
):
"""Sync tool with task=True logs warning and disables task support."""
import logging
with caplog_for_fastmcp(caplog):
caplog.set_level(logging.INFO)
mcp = FastMCP("test")
@mcp.tool(task=True)
def sync_tool(x: int) -> int:
"""A synchronous tool."""
logging.getLogger("fastmcp.myserver").info("I came from the tool!")
return x * 2
# Should have logged a warning during decoration
assert "task=True but is synchronous" in caplog.text
assert "Disabling task support" in caplog.text
# Tool should have task=False after being disabled
tool = await mcp.get_tool("sync_tool")
assert tool.task is False
# Verify execution: even if client requests task=True, should execute immediately
async with Client(mcp) as client:
task = await client.call_tool("sync_tool", {"x": 5}, task=True)
assert task.returned_immediately
result = await task.result()
assert result.data == 10
# Should have seen the log from inside the function
assert "I came from the tool!" in caplog.text
async def test_sync_tool_with_inherited_task_true_quietly_disables(
caplog: LogCaptureFixture,
):
"""Sync tool inheriting task=True from server disables quietly (no warning)."""
import logging
with caplog_for_fastmcp(caplog):
caplog.set_level(logging.INFO)
mcp = FastMCP("test", tasks=True)
@mcp.tool() # Inherits task=True from server
def sync_tool(x: int) -> int:
"""A synchronous tool."""
logging.getLogger("fastmcp.myserver").info("I came from the tool!")
return x * 2
# Should NOT have logged a warning (quietly disabled)
assert "task=True but is synchronous" not in caplog.text
# Tool should have task=False after being disabled
tool = await mcp.get_tool("sync_tool")
assert tool.task is False
# Verify execution: should execute immediately
async with Client(mcp) as client:
task = await client.call_tool("sync_tool", {"x": 3}, task=True)
assert task.returned_immediately
result = await task.result()
assert result.data == 6
# Should have seen the log from inside the function
assert "I came from the tool!" in caplog.text
async def test_sync_prompt_with_explicit_task_true_warns_and_disables(
caplog: LogCaptureFixture,
):
"""Sync prompt with task=True logs warning and disables task support."""
import logging
with caplog_for_fastmcp(caplog):
caplog.set_level(logging.INFO)
mcp = FastMCP("test")
@mcp.prompt(task=True)
def sync_prompt() -> str:
"""A synchronous prompt."""
logging.getLogger("fastmcp.myserver").info("I came from the prompt!")
return "Hello"
# Should have logged a warning during decoration
assert "task=True but is synchronous" in caplog.text
assert "Disabling task support" in caplog.text
# Prompt should have task=False
prompt = await mcp.get_prompt("sync_prompt")
assert prompt.task is False
# Verify execution: should execute immediately
async with Client(mcp) as client:
task = await client.get_prompt("sync_prompt", task=True)
assert task.returned_immediately
result = await task.result()
assert "Hello" in str(result)
# Should have seen the log from inside the function
assert "I came from the prompt!" in caplog.text
async def test_sync_prompt_with_inherited_task_true_quietly_disables(
caplog: LogCaptureFixture,
):
"""Sync prompt inheriting task=True disables quietly."""
import logging
with caplog_for_fastmcp(caplog):
caplog.set_level(logging.INFO)
mcp = FastMCP("test", tasks=True)
@mcp.prompt() # Inherits task=True from server
def sync_prompt() -> str:
"""A synchronous prompt."""
logging.getLogger("fastmcp.myserver").info("I came from the prompt!")
return "Hello"
# Should NOT have logged a warning (quietly disabled)
assert "task=True but is synchronous" not in caplog.text
# Prompt should have task=False
prompt = await mcp.get_prompt("sync_prompt")
assert prompt.task is False
# Verify execution: should execute immediately
async with Client(mcp) as client:
task = await client.get_prompt("sync_prompt", task=True)
assert task.returned_immediately
result = await task.result()
assert "Hello" in str(result)
# Should have seen the log from inside the function
assert "I came from the prompt!" in caplog.text
async def test_sync_resource_with_explicit_task_true_warns_and_disables(
caplog: LogCaptureFixture,
):
"""Sync resource with task=True logs warning and disables task support."""
import logging
with caplog_for_fastmcp(caplog):
caplog.set_level(logging.INFO)
mcp = FastMCP("test")
@mcp.resource("test://sync", task=True)
def sync_resource() -> str:
"""A synchronous resource."""
logging.getLogger("fastmcp.myserver").info("I came from the resource!")
return "data"
# Should have logged a warning during decoration
assert "task=True but is synchronous" in caplog.text
assert "Disabling task support" in caplog.text
# Resource should have task=False
resource = await mcp._resource_manager.get_resource("test://sync")
assert resource.task is False
# Verify execution: should execute immediately
async with Client(mcp) as client:
task = await client.read_resource("test://sync", task=True)
assert task.returned_immediately
result = await task.result()
assert "data" in str(result)
# Should have seen the log from inside the function
assert "I came from the resource!" in caplog.text
async def test_sync_resource_with_inherited_task_true_quietly_disables(
caplog: LogCaptureFixture,
):
"""Sync resource inheriting task=True disables quietly."""
import logging
with caplog_for_fastmcp(caplog):
caplog.set_level(logging.INFO)
mcp = FastMCP("test", tasks=True)
@mcp.resource("test://sync") # Inherits task=True from server
def sync_resource() -> str:
"""A synchronous resource."""
logging.getLogger("fastmcp.myserver").info("I came from the resource!")
return "data"
# Should NOT have logged a warning (quietly disabled)
assert "task=True but is synchronous" not in caplog.text
# Resource should have task=False
resource = await mcp._resource_manager.get_resource("test://sync")
assert resource.task is False
# Verify execution: should execute immediately
async with Client(mcp) as client:
task = await client.read_resource("test://sync", task=True)
assert task.returned_immediately
result = await task.result()
assert "data" in str(result)
# Should have seen the log from inside the function
assert "I came from the resource!" in caplog.text
async def test_async_tool_with_task_true_remains_enabled():
"""Async tools with task=True keep task support enabled."""
mcp = FastMCP("test")
@mcp.tool(task=True)
async def async_tool(x: int) -> int:
"""An async tool."""
return x * 2
# Tool should have task=True
tool = await mcp.get_tool("async_tool")
assert tool.task is True
async def test_async_prompt_with_task_true_remains_enabled():
"""Async prompts with task=True keep task support enabled."""
mcp = FastMCP("test")
@mcp.prompt(task=True)
async def async_prompt() -> str:
"""An async prompt."""
return "Hello"
# Prompt should have task=True
prompt = await mcp.get_prompt("async_prompt")
assert prompt.task is True
async def test_async_resource_with_task_true_remains_enabled():
"""Async resources with task=True keep task support enabled."""
mcp = FastMCP("test")
@mcp.resource("test://async", task=True)
async def async_resource() -> str:
"""An async resource."""
return "data"
# Resource should have task=True
resource = await mcp._resource_manager.get_resource("test://async")
assert resource.task is True