Implement SEP-2663 tasks extension: TasksExtension, poll-based task lifecycle

TasksExtension serves io.modelcontextprotocol/tasks on the extension API:
a decide-and-task tools/call interceptor (era-gated to modern connections),
tasks/get with inlined results and inputRequests, tasks/update delivering
poll-based in-task elicitation, tasks/cancel, durable creation, and
auth-scoped task isolation. Wire models validate against the vendored
ext-tasks schema. Worker-side Context hooks are refcounted so sibling
servers cannot strand each other's workers.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeremiah Lowin 2026-07-21 23:00:38 -04:00
commit 5fa2883670
No known key found for this signature in database
72 changed files with 3908 additions and 6512 deletions

View file

@ -1,21 +1,16 @@
"""
Tests that synchronous functions cannot be used as background tasks.
Docket requires async functions for background execution. FastMCP raises
ValueError when task=True is used with a sync function.
SEP-2663 tasks are tools-only. Docket requires async functions for background
execution, so FastMCP raises ValueError when task=True is used with a sync tool
function. These are registration-time checks and need no running server.
"""
import pytest
from fastmcp import FastMCP
from fastmcp.prompts.function_prompt import FunctionPrompt
from fastmcp.resources.function_resource import FunctionResource
from fastmcp.tools.function_tool import FunctionTool
pytestmark = pytest.mark.skip(
reason="Phase 3: requires TasksExtension (SEP-2663 adapter)"
)
async def test_sync_tool_with_explicit_task_true_raises():
"""Sync tool with task=True raises ValueError."""
@ -45,62 +40,6 @@ async def test_sync_tool_with_inherited_task_true_raises():
return x * 2
async def test_sync_prompt_with_explicit_task_true_raises():
"""Sync prompt with task=True raises ValueError."""
mcp = FastMCP("test")
with pytest.raises(
ValueError, match="uses a sync function but has task execution enabled"
):
@mcp.prompt(task=True)
def sync_prompt() -> str:
"""A synchronous prompt."""
return "Hello"
async def test_sync_prompt_with_inherited_task_true_raises():
"""Sync prompt inheriting task=True from server raises ValueError."""
mcp = FastMCP("test", tasks=True)
with pytest.raises(
ValueError, match="uses a sync function but has task execution enabled"
):
@mcp.prompt() # Inherits task=True from server
def sync_prompt() -> str:
"""A synchronous prompt."""
return "Hello"
async def test_sync_resource_with_explicit_task_true_raises():
"""Sync resource with task=True raises ValueError."""
mcp = FastMCP("test")
with pytest.raises(
ValueError, match="uses a sync function but has task execution enabled"
):
@mcp.resource("test://sync", task=True)
def sync_resource() -> str:
"""A synchronous resource."""
return "data"
async def test_sync_resource_with_inherited_task_true_raises():
"""Sync resource inheriting task=True from server raises ValueError."""
mcp = FastMCP("test", tasks=True)
with pytest.raises(
ValueError, match="uses a sync function but has task execution enabled"
):
@mcp.resource("test://sync") # Inherits task=True from server
def sync_resource() -> str:
"""A synchronous resource."""
return "data"
async def test_async_tool_with_task_true_remains_enabled():
"""Async tools with task=True keep task support enabled."""
mcp = FastMCP("test")
@ -110,42 +49,11 @@ async def test_async_tool_with_task_true_remains_enabled():
"""An async tool."""
return x * 2
# Tool should have task mode="optional" and be a FunctionTool
tool = await mcp.get_tool("async_tool")
assert isinstance(tool, FunctionTool)
assert tool.task_config.mode == "optional"
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 mode="optional" and be a FunctionPrompt
prompt = await mcp.get_prompt("async_prompt")
assert isinstance(prompt, FunctionPrompt)
assert prompt.task_config.mode == "optional"
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 mode="optional" and be a FunctionResource
resource = await mcp.get_resource("test://async")
assert isinstance(resource, FunctionResource)
assert resource.task_config.mode == "optional"
async def test_sync_tool_with_task_false_works():
"""Sync tool with explicit task=False works (no error)."""
mcp = FastMCP("test", tasks=True)
@ -160,36 +68,8 @@ async def test_sync_tool_with_task_false_works():
assert tool.task_config.mode == "forbidden"
async def test_sync_prompt_with_task_false_works():
"""Sync prompt with explicit task=False works (no error)."""
mcp = FastMCP("test", tasks=True)
@mcp.prompt(task=False) # Explicitly disable
def sync_prompt() -> str:
"""A synchronous prompt."""
return "Hello"
prompt = await mcp.get_prompt("sync_prompt")
assert isinstance(prompt, FunctionPrompt)
assert prompt.task_config.mode == "forbidden"
async def test_sync_resource_with_task_false_works():
"""Sync resource with explicit task=False works (no error)."""
mcp = FastMCP("test", tasks=True)
@mcp.resource("test://sync", task=False) # Explicitly disable
def sync_resource() -> str:
"""A synchronous resource."""
return "data"
resource = await mcp.get_resource("test://sync")
assert isinstance(resource, FunctionResource)
assert resource.task_config.mode == "forbidden"
# =============================================================================
# Callable classes and staticmethods with async __call__
# Callable classes with async __call__
# =============================================================================
@ -201,24 +81,10 @@ async def test_async_callable_class_tool_with_task_true_works():
async def __call__(self, x: int) -> int:
return x * 2
# Callable classes use Tool.from_function() directly
tool = Tool.from_function(AsyncCallableTool(), task=True)
assert tool.task_config.mode == "optional"
async def test_async_callable_class_prompt_with_task_true_works():
"""Callable class with async __call__ and task=True should work."""
from fastmcp.prompts import Prompt
class AsyncCallablePrompt:
async def __call__(self) -> str:
return "Hello"
# Callable classes use Prompt.from_function() directly
prompt = Prompt.from_function(AsyncCallablePrompt(), task=True)
assert prompt.task_config.mode == "optional"
async def test_sync_callable_class_tool_with_task_true_raises():
"""Callable class with sync __call__ and task=True should raise."""
from fastmcp.tools import Tool