mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
* 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>
267 lines
8.9 KiB
Python
267 lines
8.9 KiB
Python
"""
|
|
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
|