60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
1125 lines
41 KiB
Python
1125 lines
41 KiB
Python
"""
|
|
Tests for MCP SEP-1686 task protocol support through mounted servers.
|
|
|
|
Verifies that tasks work seamlessly when calling tools/prompts/resources
|
|
on mounted child servers through a parent server.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import mcp_types as mt
|
|
import pytest
|
|
from docket import Docket
|
|
from mcp_types import Tool as MCPTool
|
|
from mcp_types import ToolExecution
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.client import Client
|
|
from fastmcp.prompts.base import PromptResult
|
|
from fastmcp.resources.base import ResourceResult
|
|
from fastmcp.server.dependencies import CurrentDocket, CurrentFastMCP
|
|
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
|
|
from fastmcp.server.providers.proxy import ProxyTool
|
|
from fastmcp.server.tasks import TaskConfig
|
|
from fastmcp.tools.base import ToolResult
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_docket_memory_server():
|
|
"""Reset the shared Docket memory server between tests.
|
|
|
|
Docket uses a class-level FakeServer instance for memory:// URLs which
|
|
persists between tests, causing test isolation issues. This fixture
|
|
clears that shared state before each test.
|
|
"""
|
|
# Clear the shared FakeServer before each test
|
|
if hasattr(Docket, "_memory_server"):
|
|
delattr(Docket, "_memory_server")
|
|
yield
|
|
# Clean up after test as well
|
|
if hasattr(Docket, "_memory_server"):
|
|
delattr(Docket, "_memory_server")
|
|
|
|
|
|
@pytest.fixture
|
|
def child_server():
|
|
"""Create a child server with task-enabled components."""
|
|
mcp = FastMCP("child-server")
|
|
|
|
@mcp.tool(task=True)
|
|
async def multiply(a: int, b: int) -> int:
|
|
"""Multiply two numbers."""
|
|
return a * b
|
|
|
|
@mcp.tool(task=True)
|
|
async def slow_child_tool(duration: float = 0.1) -> str:
|
|
"""A child tool that takes time to execute."""
|
|
await asyncio.sleep(duration)
|
|
return "child completed"
|
|
|
|
@mcp.tool(task=False)
|
|
async def sync_child_tool(message: str) -> str:
|
|
"""Child tool that only supports synchronous execution."""
|
|
return f"child sync: {message}"
|
|
|
|
@mcp.prompt(task=True)
|
|
async def child_prompt(topic: str) -> str:
|
|
"""A child prompt that can execute as a task."""
|
|
return f"Here is information about {topic} from the child server."
|
|
|
|
@mcp.resource("child://data.txt", task=True)
|
|
async def child_resource() -> str:
|
|
"""A child resource that can be read as a task."""
|
|
return "Data from child server"
|
|
|
|
@mcp.resource("child://item/{item_id}.json", task=True)
|
|
async def child_item_resource(item_id: str) -> str:
|
|
"""A child resource template that can execute as a task."""
|
|
return f'{{"itemId": "{item_id}", "source": "child"}}'
|
|
|
|
return mcp
|
|
|
|
|
|
@pytest.fixture
|
|
def parent_server(child_server):
|
|
"""Create a parent server with the child mounted."""
|
|
parent = FastMCP("parent-server")
|
|
|
|
@parent.tool(task=True)
|
|
async def parent_tool(value: int) -> int:
|
|
"""A tool on the parent server."""
|
|
return value * 10
|
|
|
|
# Mount child with prefix
|
|
parent.mount(child_server, namespace="child")
|
|
|
|
return parent
|
|
|
|
|
|
@pytest.fixture
|
|
def parent_server_no_prefix(child_server):
|
|
"""Create a parent server with child mounted without prefix."""
|
|
parent = FastMCP("parent-no-prefix")
|
|
parent.mount(child_server) # No prefix
|
|
return parent
|
|
|
|
|
|
class TestMountedToolTasks:
|
|
"""Test task execution for mounted tools."""
|
|
|
|
async def test_mounted_tool_task_returns_task_object(self, parent_server):
|
|
"""Mounted tool called with task=True returns a task object."""
|
|
async with Client(parent_server) as client:
|
|
# Tool name is prefixed: child_multiply
|
|
task = await client.call_tool("child_multiply", {"a": 6, "b": 7}, task=True)
|
|
|
|
assert task is not None
|
|
assert hasattr(task, "task_id")
|
|
assert isinstance(task.task_id, str)
|
|
assert len(task.task_id) > 0
|
|
|
|
async def test_mounted_tool_task_executes_in_background(self, parent_server):
|
|
"""Mounted tool task executes in background."""
|
|
async with Client(parent_server) as client:
|
|
task = await client.call_tool("child_multiply", {"a": 3, "b": 4}, task=True)
|
|
|
|
# Should execute in background
|
|
assert not task.returned_immediately
|
|
|
|
async def test_mounted_tool_task_returns_correct_result(
|
|
self, parent_server: FastMCP
|
|
):
|
|
"""Mounted tool task returns correct result."""
|
|
async with Client(parent_server) as client:
|
|
task = await client.call_tool("child_multiply", {"a": 8, "b": 9}, task=True)
|
|
|
|
result = await task.result()
|
|
assert result.data == 72
|
|
|
|
async def test_mounted_tool_task_status(self, parent_server):
|
|
"""Can poll task status for mounted tool."""
|
|
async with Client(parent_server) as client:
|
|
task = await client.call_tool(
|
|
"child_slow_child_tool", {"duration": 0.5}, task=True
|
|
)
|
|
|
|
# Check status while running
|
|
status = await task.status()
|
|
assert status.status in ["working", "completed"]
|
|
|
|
# Wait for completion
|
|
await task.wait(timeout=2.0)
|
|
|
|
# Check status after completion
|
|
status = await task.status()
|
|
assert status.status == "completed"
|
|
|
|
@pytest.mark.timeout(10)
|
|
async def test_mounted_tool_task_cancellation(self, parent_server):
|
|
"""Can cancel a mounted tool task."""
|
|
async with Client(parent_server) as client:
|
|
task = await client.call_tool(
|
|
"child_slow_child_tool", {"duration": 10.0}, task=True
|
|
)
|
|
|
|
# Let it start
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Cancel the task
|
|
await task.cancel()
|
|
|
|
# Check status
|
|
status = await task.status()
|
|
assert status.status == "cancelled"
|
|
|
|
async def test_graceful_degradation_sync_mounted_tool(self, parent_server):
|
|
"""Sync-only mounted tool returns error with task=True."""
|
|
async with Client(parent_server) as client:
|
|
task = await client.call_tool(
|
|
"child_sync_child_tool",
|
|
{"message": "hello"},
|
|
task=True,
|
|
raise_on_error=False,
|
|
)
|
|
|
|
# Should return immediately with an error
|
|
assert task.returned_immediately
|
|
|
|
result = await task.result()
|
|
assert result.is_error
|
|
|
|
async def test_parent_and_mounted_tools_both_work(self, parent_server):
|
|
"""Both parent and mounted tools work as tasks."""
|
|
async with Client(parent_server) as client:
|
|
# Parent tool
|
|
parent_task = await client.call_tool("parent_tool", {"value": 5}, task=True)
|
|
# Mounted tool
|
|
child_task = await client.call_tool(
|
|
"child_multiply", {"a": 2, "b": 3}, task=True
|
|
)
|
|
|
|
parent_result = await parent_task.result()
|
|
child_result = await child_task.result()
|
|
|
|
assert parent_result.data == 50
|
|
assert child_result.data == 6
|
|
|
|
|
|
class TestMountedToolTasksNoPrefix:
|
|
"""Test task execution for mounted tools without prefix."""
|
|
|
|
async def test_mounted_tool_without_prefix_task_works(
|
|
self, parent_server_no_prefix
|
|
):
|
|
"""Mounted tool without prefix works as task."""
|
|
async with Client(parent_server_no_prefix) as client:
|
|
# No prefix, so tool keeps original name
|
|
task = await client.call_tool("multiply", {"a": 5, "b": 6}, task=True)
|
|
|
|
assert not task.returned_immediately
|
|
|
|
result = await task.result()
|
|
assert result.data == 30
|
|
|
|
|
|
class TestMountedPromptTasks:
|
|
"""Test task execution for mounted prompts."""
|
|
|
|
async def test_mounted_prompt_task_returns_task_object(self, parent_server):
|
|
"""Mounted prompt called with task=True returns a task object."""
|
|
async with Client(parent_server) as client:
|
|
# Prompt name is prefixed: child_child_prompt
|
|
task = await client.get_prompt(
|
|
"child_child_prompt", {"topic": "FastMCP"}, task=True
|
|
)
|
|
|
|
assert task is not None
|
|
assert hasattr(task, "task_id")
|
|
assert isinstance(task.task_id, str)
|
|
|
|
@pytest.mark.xfail(
|
|
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
|
|
"ReadResourceRequestParams; prompt/resource task submission is not "
|
|
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
|
|
strict=True,
|
|
)
|
|
async def test_mounted_prompt_task_executes_in_background(self, parent_server):
|
|
"""Mounted prompt task executes in background."""
|
|
async with Client(parent_server) as client:
|
|
task = await client.get_prompt(
|
|
"child_child_prompt", {"topic": "testing"}, task=True
|
|
)
|
|
|
|
assert not task.returned_immediately
|
|
|
|
async def test_mounted_prompt_task_returns_correct_result(
|
|
self, parent_server: FastMCP
|
|
):
|
|
"""Mounted prompt task returns correct result."""
|
|
async with Client(parent_server) as client:
|
|
task = await client.get_prompt(
|
|
"child_child_prompt", {"topic": "MCP protocol"}, task=True
|
|
)
|
|
|
|
result = await task.result()
|
|
assert "MCP protocol" in result.messages[0].content.text
|
|
assert "child server" in result.messages[0].content.text
|
|
|
|
|
|
class TestMountedResourceTasks:
|
|
"""Test task execution for mounted resources."""
|
|
|
|
async def test_mounted_resource_task_returns_task_object(self, parent_server):
|
|
"""Mounted resource read with task=True returns a task object."""
|
|
async with Client(parent_server) as client:
|
|
# Resource URI is prefixed: child://child/data.txt
|
|
task = await client.read_resource("child://child/data.txt", task=True)
|
|
|
|
assert task is not None
|
|
assert hasattr(task, "task_id")
|
|
assert isinstance(task.task_id, str)
|
|
|
|
@pytest.mark.xfail(
|
|
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
|
|
"ReadResourceRequestParams; prompt/resource task submission is not "
|
|
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
|
|
strict=True,
|
|
)
|
|
async def test_mounted_resource_task_executes_in_background(self, parent_server):
|
|
"""Mounted resource task executes in background."""
|
|
async with Client(parent_server) as client:
|
|
task = await client.read_resource("child://child/data.txt", task=True)
|
|
|
|
assert not task.returned_immediately
|
|
|
|
async def test_mounted_resource_task_returns_correct_result(self, parent_server):
|
|
"""Mounted resource task returns correct result."""
|
|
async with Client(parent_server) as client:
|
|
task = await client.read_resource("child://child/data.txt", task=True)
|
|
|
|
result = await task.result()
|
|
assert len(result) > 0
|
|
assert "Data from child server" in result[0].text
|
|
|
|
@pytest.mark.xfail(
|
|
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
|
|
"ReadResourceRequestParams; prompt/resource task submission is not "
|
|
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
|
|
strict=True,
|
|
)
|
|
async def test_mounted_resource_template_task(self, parent_server):
|
|
"""Mounted resource template with task=True works."""
|
|
async with Client(parent_server) as client:
|
|
task = await client.read_resource("child://child/item/99.json", task=True)
|
|
|
|
assert not task.returned_immediately
|
|
|
|
result = await task.result()
|
|
assert '"itemId": "99"' in result[0].text
|
|
assert '"source": "child"' in result[0].text
|
|
|
|
|
|
class TestMountedTaskDependencies:
|
|
"""Test that dependencies work correctly in mounted task execution."""
|
|
|
|
async def test_mounted_task_receives_docket_dependency(self):
|
|
"""Mounted tool task receives CurrentDocket dependency."""
|
|
child = FastMCP("dep-child")
|
|
received_docket = []
|
|
|
|
@child.tool(task=True)
|
|
async def tool_with_docket(docket: CurrentDocket = CurrentDocket()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form]
|
|
received_docket.append(docket)
|
|
return f"docket available: {docket is not None}"
|
|
|
|
parent = FastMCP("dep-parent")
|
|
parent.mount(child, namespace="child")
|
|
|
|
async with Client(parent) as client:
|
|
task = await client.call_tool("child_tool_with_docket", {}, task=True)
|
|
result = await task.result()
|
|
|
|
assert "docket available: True" in str(result)
|
|
assert len(received_docket) == 1
|
|
assert received_docket[0] is not None
|
|
|
|
async def test_mounted_task_receives_server_dependency(self):
|
|
"""Mounted tool task receives CurrentFastMCP dependency."""
|
|
child = FastMCP("server-dep-child")
|
|
received_server = []
|
|
|
|
@child.tool(task=True)
|
|
async def tool_with_server(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form]
|
|
received_server.append(server)
|
|
return f"server name: {server.name}"
|
|
|
|
parent = FastMCP("server-dep-parent")
|
|
parent.mount(child, namespace="child")
|
|
|
|
async with Client(parent) as client:
|
|
task = await client.call_tool("child_tool_with_server", {}, task=True)
|
|
await task.result()
|
|
|
|
assert len(received_server) == 1
|
|
assert received_server[0].name == "server-dep-child"
|
|
|
|
|
|
class TestMountedTaskServerContext:
|
|
"""Test that background tasks on mounted servers resolve to the child server (#3571)."""
|
|
|
|
async def test_current_fastmcp_resolves_to_child_server(self):
|
|
"""CurrentFastMCP() inside a mounted background task returns the child server."""
|
|
child = FastMCP("child")
|
|
received_server: list[FastMCP] = []
|
|
|
|
@child.tool(task=True)
|
|
async def whoami(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form]
|
|
received_server.append(server)
|
|
return f"server name: {server.name}"
|
|
|
|
parent = FastMCP("parent")
|
|
parent.mount(child, namespace="child")
|
|
|
|
async with Client(parent) as client:
|
|
task = await client.call_tool("child_whoami", {}, task=True)
|
|
result = await task.result()
|
|
|
|
assert len(received_server) == 1
|
|
assert received_server[0].name == "child"
|
|
assert "server name: child" in str(result)
|
|
|
|
async def test_context_fastmcp_resolves_to_child_server(self):
|
|
"""ctx.fastmcp inside a mounted background task returns the child server."""
|
|
from fastmcp import Context
|
|
|
|
child = FastMCP("child")
|
|
received_server: list[FastMCP] = []
|
|
|
|
@child.tool(task=True)
|
|
async def whoami_ctx(ctx: Context) -> str:
|
|
received_server.append(ctx.fastmcp)
|
|
return f"context server: {ctx.fastmcp.name}"
|
|
|
|
parent = FastMCP("parent")
|
|
parent.mount(child, namespace="child")
|
|
|
|
async with Client(parent) as client:
|
|
task = await client.call_tool("child_whoami_ctx", {}, task=True)
|
|
result = await task.result()
|
|
|
|
assert len(received_server) == 1
|
|
assert received_server[0].name == "child"
|
|
assert "context server: child" in str(result)
|
|
|
|
async def test_nested_mount_resolves_to_innermost_server(self):
|
|
"""Doubly-nested mounts resolve to the innermost child server."""
|
|
grandchild = FastMCP("grandchild")
|
|
received_server: list[FastMCP] = []
|
|
|
|
@grandchild.tool(task=True)
|
|
async def deep_whoami(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form]
|
|
received_server.append(server)
|
|
return f"server name: {server.name}"
|
|
|
|
child = FastMCP("child")
|
|
child.mount(grandchild, namespace="gc")
|
|
|
|
parent = FastMCP("parent")
|
|
parent.mount(child, namespace="child")
|
|
|
|
async with Client(parent) as client:
|
|
task = await client.call_tool("child_gc_deep_whoami", {}, task=True)
|
|
result = await task.result()
|
|
|
|
assert len(received_server) == 1
|
|
assert received_server[0].name == "grandchild"
|
|
assert "server name: grandchild" in str(result)
|
|
|
|
|
|
class TestMultipleMounts:
|
|
"""Test tasks with multiple mounted servers."""
|
|
|
|
async def test_tasks_work_with_multiple_mounts(self):
|
|
"""Tasks work correctly with multiple mounted servers."""
|
|
child1 = FastMCP("child1")
|
|
child2 = FastMCP("child2")
|
|
|
|
@child1.tool(task=True)
|
|
async def add(a: int, b: int) -> int:
|
|
return a + b
|
|
|
|
@child2.tool(task=True)
|
|
async def subtract(a: int, b: int) -> int:
|
|
return a - b
|
|
|
|
parent = FastMCP("multi-parent")
|
|
parent.mount(child1, namespace="math1")
|
|
parent.mount(child2, namespace="math2")
|
|
|
|
async with Client(parent) as client:
|
|
task1 = await client.call_tool("math1_add", {"a": 10, "b": 5}, task=True)
|
|
task2 = await client.call_tool(
|
|
"math2_subtract", {"a": 10, "b": 5}, task=True
|
|
)
|
|
|
|
result1 = await task1.result()
|
|
result2 = await task2.result()
|
|
|
|
assert result1.data == 15
|
|
assert result2.data == 5
|
|
|
|
|
|
class TestMountedFunctionNameCollisions:
|
|
"""Test task execution when mounted servers have identically-named functions."""
|
|
|
|
async def test_multiple_mounts_with_same_function_names(self):
|
|
"""Two mounted servers with identically-named functions don't collide."""
|
|
child1 = FastMCP("child1")
|
|
child2 = FastMCP("child2")
|
|
|
|
@child1.tool(task=True)
|
|
async def process(value: int) -> int:
|
|
return value * 2 # Double
|
|
|
|
@child2.tool(task=True)
|
|
async def process(value: int) -> int: # noqa: F811
|
|
return value * 3 # Triple
|
|
|
|
parent = FastMCP("parent")
|
|
parent.mount(child1, namespace="c1")
|
|
parent.mount(child2, namespace="c2")
|
|
|
|
async with Client(parent) as client:
|
|
# Both should execute their own implementation
|
|
task1 = await client.call_tool("c1_process", {"value": 10}, task=True)
|
|
task2 = await client.call_tool("c2_process", {"value": 10}, task=True)
|
|
|
|
result1 = await task1.result()
|
|
result2 = await task2.result()
|
|
|
|
assert result1.data == 20 # child1's process (doubles)
|
|
assert result2.data == 30 # child2's process (triples)
|
|
|
|
async def test_no_prefix_mount_collision(self):
|
|
"""No-prefix mounts with same tool name - last mount wins."""
|
|
child1 = FastMCP("child1")
|
|
child2 = FastMCP("child2")
|
|
|
|
@child1.tool(task=True)
|
|
async def process(value: int) -> int:
|
|
return value * 2
|
|
|
|
@child2.tool(task=True)
|
|
async def process(value: int) -> int: # noqa: F811
|
|
return value * 3
|
|
|
|
parent = FastMCP("parent")
|
|
parent.mount(child1) # No prefix
|
|
parent.mount(child2) # No prefix - overwrites child1's "process"
|
|
|
|
async with Client(parent) as client:
|
|
# Last mount wins - child2's process should execute
|
|
task = await client.call_tool("process", {"value": 10}, task=True)
|
|
result = await task.result()
|
|
assert result.data == 30 # child2's process (triples)
|
|
|
|
async def test_nested_mount_prefix_accumulation(self):
|
|
"""Nested mounts accumulate prefixes correctly for tasks."""
|
|
grandchild = FastMCP("gc")
|
|
child = FastMCP("child")
|
|
parent = FastMCP("parent")
|
|
|
|
@grandchild.tool(task=True)
|
|
async def deep_tool() -> str:
|
|
return "deep"
|
|
|
|
child.mount(grandchild, namespace="gc")
|
|
parent.mount(child, namespace="child")
|
|
|
|
async with Client(parent) as client:
|
|
# Tool should be accessible and execute correctly
|
|
task = await client.call_tool("child_gc_deep_tool", {}, task=True)
|
|
result = await task.result()
|
|
assert result.data == "deep"
|
|
|
|
|
|
class TestMountedTaskList:
|
|
"""Test task listing with mounted servers."""
|
|
|
|
async def test_list_tasks_includes_mounted_tasks(self, parent_server):
|
|
"""Task list includes tasks from mounted server tools."""
|
|
async with Client(parent_server) as client:
|
|
# Create tasks on both parent and mounted tools
|
|
parent_task = await client.call_tool("parent_tool", {"value": 1}, task=True)
|
|
child_task = await client.call_tool(
|
|
"child_multiply", {"a": 2, "b": 2}, task=True
|
|
)
|
|
|
|
# Wait for completion
|
|
await parent_task.wait(timeout=2.0)
|
|
await child_task.wait(timeout=2.0)
|
|
|
|
# List all tasks - returns dict with "tasks" key
|
|
tasks_response = await client.list_tasks()
|
|
|
|
task_ids = [t["taskId"] for t in tasks_response["tasks"]]
|
|
assert parent_task.task_id in task_ids
|
|
assert child_task.task_id in task_ids
|
|
|
|
|
|
class TestMountedTaskMetadata:
|
|
"""Test task metadata exposure for mounted tools."""
|
|
|
|
async def test_mounted_tool_list_preserves_task_support_metadata(self):
|
|
"""Mounted tools should preserve execution.task_support in tools/list."""
|
|
child = FastMCP("child")
|
|
|
|
@child.tool(task=True)
|
|
async def foo() -> dict[str, bool]:
|
|
return {"ok": True}
|
|
|
|
parent = FastMCP("parent")
|
|
parent.mount(child)
|
|
|
|
child_tools = await child.list_tools()
|
|
parent_tools = await parent.list_tools()
|
|
|
|
child_tool = next(t for t in child_tools if t.name == "foo")
|
|
parent_tool = next(t for t in parent_tools if t.name == "foo")
|
|
|
|
child_mcp_tool = child_tool.to_mcp_tool(name=child_tool.name)
|
|
parent_mcp_tool = parent_tool.to_mcp_tool(name=parent_tool.name)
|
|
|
|
assert child_mcp_tool.execution is not None
|
|
assert parent_mcp_tool.execution is not None
|
|
assert child_mcp_tool.execution.task_support == "optional"
|
|
assert parent_mcp_tool.execution.task_support == "optional"
|
|
|
|
async def test_proxy_tool_preserves_execution_metadata(self):
|
|
"""ProxyTool.from_mcp_tool should propagate execution.task_support (#3569)."""
|
|
mcp_tool = MCPTool(
|
|
name="remote_task_tool",
|
|
description="A remote tool that supports tasks",
|
|
input_schema={"type": "object", "properties": {}},
|
|
execution=ToolExecution(task_support="optional"),
|
|
)
|
|
|
|
proxy = ProxyTool.from_mcp_tool(lambda: None, mcp_tool) # ty: ignore[invalid-argument-type]
|
|
result = proxy.to_mcp_tool(name=proxy.name)
|
|
|
|
assert result.execution is not None
|
|
assert result.execution.task_support == "optional"
|
|
|
|
|
|
class TestMountedTaskConfigModes:
|
|
"""Test TaskConfig mode enforcement for mounted tools."""
|
|
|
|
@pytest.fixture
|
|
def child_with_modes(self):
|
|
"""Create a child server with tools in all three TaskConfig modes."""
|
|
mcp = FastMCP("child-modes", tasks=False)
|
|
|
|
@mcp.tool(task=TaskConfig(mode="optional"))
|
|
async def optional_tool() -> str:
|
|
"""Tool that supports both sync and task execution."""
|
|
return "optional result"
|
|
|
|
@mcp.tool(task=TaskConfig(mode="required"))
|
|
async def required_tool() -> str:
|
|
"""Tool that requires task execution."""
|
|
return "required result"
|
|
|
|
@mcp.tool(task=TaskConfig(mode="forbidden"))
|
|
async def forbidden_tool() -> str:
|
|
"""Tool that forbids task execution."""
|
|
return "forbidden result"
|
|
|
|
return mcp
|
|
|
|
@pytest.fixture
|
|
def parent_with_modes(self, child_with_modes):
|
|
"""Create a parent server with the child mounted."""
|
|
parent = FastMCP("parent-modes")
|
|
parent.mount(child_with_modes, namespace="child")
|
|
return parent
|
|
|
|
async def test_optional_mode_sync_through_mount(self, parent_with_modes):
|
|
"""Optional mode tool works without task through mount."""
|
|
async with Client(parent_with_modes) as client:
|
|
result = await client.call_tool("child_optional_tool", {})
|
|
assert "optional result" in str(result)
|
|
|
|
async def test_optional_mode_task_through_mount(self, parent_with_modes):
|
|
"""Optional mode tool works with task through mount."""
|
|
async with Client(parent_with_modes) as client:
|
|
task = await client.call_tool("child_optional_tool", {}, task=True)
|
|
assert task is not None
|
|
result = await task.result()
|
|
assert result.data == "optional result"
|
|
|
|
async def test_required_mode_with_task_through_mount(self, parent_with_modes):
|
|
"""Required mode tool succeeds with task through mount."""
|
|
async with Client(parent_with_modes) as client:
|
|
task = await client.call_tool("child_required_tool", {}, task=True)
|
|
assert task is not None
|
|
result = await task.result()
|
|
assert result.data == "required result"
|
|
|
|
async def test_required_mode_without_task_through_mount(self, parent_with_modes):
|
|
"""Required mode tool errors without task through mount."""
|
|
from fastmcp.exceptions import ToolError
|
|
|
|
async with Client(parent_with_modes) as client:
|
|
with pytest.raises(ToolError) as exc_info:
|
|
await client.call_tool("child_required_tool", {})
|
|
|
|
assert "requires task-augmented execution" in str(exc_info.value)
|
|
|
|
async def test_forbidden_mode_sync_through_mount(self, parent_with_modes):
|
|
"""Forbidden mode tool works without task through mount."""
|
|
async with Client(parent_with_modes) as client:
|
|
result = await client.call_tool("child_forbidden_tool", {})
|
|
assert "forbidden result" in str(result)
|
|
|
|
async def test_forbidden_mode_with_task_through_mount(self, parent_with_modes):
|
|
"""Forbidden mode tool degrades gracefully with task through mount."""
|
|
async with Client(parent_with_modes) as client:
|
|
task = await client.call_tool(
|
|
"child_forbidden_tool", {}, task=True, raise_on_error=False
|
|
)
|
|
|
|
# Should return immediately (graceful degradation)
|
|
assert task.returned_immediately
|
|
|
|
result = await task.result()
|
|
# Result is available but may indicate error or sync execution
|
|
assert result is not None
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Middleware classes for tracing tests
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class ToolTracingMiddleware(Middleware):
|
|
"""Middleware that traces tool calls."""
|
|
|
|
def __init__(self, name: str, calls: list[str]):
|
|
super().__init__()
|
|
self._name = name
|
|
self._calls = calls
|
|
|
|
async def on_call_tool(
|
|
self,
|
|
context: MiddlewareContext[mt.CallToolRequestParams],
|
|
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
|
|
) -> ToolResult:
|
|
self._calls.append(f"{self._name}:before")
|
|
result = await call_next(context)
|
|
self._calls.append(f"{self._name}:after")
|
|
return result
|
|
|
|
|
|
class ResourceTracingMiddleware(Middleware):
|
|
"""Middleware that traces resource reads."""
|
|
|
|
def __init__(self, name: str, calls: list[str]):
|
|
super().__init__()
|
|
self._name = name
|
|
self._calls = calls
|
|
|
|
async def on_read_resource(
|
|
self,
|
|
context: MiddlewareContext[mt.ReadResourceRequestParams],
|
|
call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult],
|
|
) -> ResourceResult:
|
|
self._calls.append(f"{self._name}:before")
|
|
result = await call_next(context)
|
|
self._calls.append(f"{self._name}:after")
|
|
return result
|
|
|
|
|
|
class PromptTracingMiddleware(Middleware):
|
|
"""Middleware that traces prompt gets."""
|
|
|
|
def __init__(self, name: str, calls: list[str]):
|
|
super().__init__()
|
|
self._name = name
|
|
self._calls = calls
|
|
|
|
async def on_get_prompt(
|
|
self,
|
|
context: MiddlewareContext[mt.GetPromptRequestParams],
|
|
call_next: CallNext[mt.GetPromptRequestParams, PromptResult],
|
|
) -> PromptResult:
|
|
self._calls.append(f"{self._name}:before")
|
|
result = await call_next(context)
|
|
self._calls.append(f"{self._name}:after")
|
|
return result
|
|
|
|
|
|
class TestMiddlewareWithMountedTasks:
|
|
"""Test that middleware runs at all levels when executing background tasks.
|
|
|
|
For background tasks, middleware runs during task submission (wrapping the MCP
|
|
request handling that queues to Docket). The actual function execution happens
|
|
later in the Docket worker, after the middleware chain completes.
|
|
"""
|
|
|
|
async def test_tool_middleware_runs_with_background_task(self):
|
|
"""Middleware runs at parent, child, and grandchild levels for tool tasks."""
|
|
calls: list[str] = []
|
|
|
|
grandchild = FastMCP("Grandchild")
|
|
|
|
@grandchild.tool(task=True)
|
|
async def compute(x: int) -> int:
|
|
calls.append("grandchild:tool")
|
|
return x * 2
|
|
|
|
grandchild.add_middleware(ToolTracingMiddleware("grandchild", calls))
|
|
|
|
child = FastMCP("Child")
|
|
child.mount(grandchild, namespace="gc")
|
|
child.add_middleware(ToolTracingMiddleware("child", calls))
|
|
|
|
parent = FastMCP("Parent")
|
|
parent.mount(child, namespace="c")
|
|
parent.add_middleware(ToolTracingMiddleware("parent", calls))
|
|
|
|
async with Client(parent) as client:
|
|
task = await client.call_tool("c_gc_compute", {"x": 5}, task=True)
|
|
result = await task.result()
|
|
assert result.data == 10
|
|
|
|
# Middleware runs during task submission (before/after queuing to Docket)
|
|
# Function executes later in Docket worker
|
|
assert calls == [
|
|
"parent:before",
|
|
"child:before",
|
|
"grandchild:before",
|
|
"grandchild:after",
|
|
"child:after",
|
|
"parent:after",
|
|
"grandchild:tool", # Executes in Docket after middleware completes
|
|
]
|
|
|
|
@pytest.mark.xfail(
|
|
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
|
|
"ReadResourceRequestParams; prompt/resource task submission is not "
|
|
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
|
|
strict=True,
|
|
)
|
|
async def test_resource_middleware_runs_with_background_task(self):
|
|
"""Middleware runs at parent, child, and grandchild levels for resource tasks."""
|
|
calls: list[str] = []
|
|
|
|
grandchild = FastMCP("Grandchild")
|
|
|
|
@grandchild.resource("data://value", task=True)
|
|
async def get_data() -> str:
|
|
calls.append("grandchild:resource")
|
|
return "result"
|
|
|
|
grandchild.add_middleware(ResourceTracingMiddleware("grandchild", calls))
|
|
|
|
child = FastMCP("Child")
|
|
child.mount(grandchild, namespace="gc")
|
|
child.add_middleware(ResourceTracingMiddleware("child", calls))
|
|
|
|
parent = FastMCP("Parent")
|
|
parent.mount(child, namespace="c")
|
|
parent.add_middleware(ResourceTracingMiddleware("parent", calls))
|
|
|
|
async with Client(parent) as client:
|
|
task = await client.read_resource("data://c/gc/value", task=True)
|
|
result = await task.result()
|
|
assert result[0].text == "result"
|
|
|
|
# Middleware runs during task submission, function in Docket
|
|
assert calls == [
|
|
"parent:before",
|
|
"child:before",
|
|
"grandchild:before",
|
|
"grandchild:after",
|
|
"child:after",
|
|
"parent:after",
|
|
"grandchild:resource",
|
|
]
|
|
|
|
@pytest.mark.xfail(
|
|
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
|
|
"ReadResourceRequestParams; prompt/resource task submission is not "
|
|
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
|
|
strict=True,
|
|
)
|
|
async def test_prompt_middleware_runs_with_background_task(self):
|
|
"""Middleware runs at parent, child, and grandchild levels for prompt tasks."""
|
|
calls: list[str] = []
|
|
|
|
grandchild = FastMCP("Grandchild")
|
|
|
|
@grandchild.prompt(task=True)
|
|
async def greet(name: str) -> str:
|
|
calls.append("grandchild:prompt")
|
|
return f"Hello, {name}!"
|
|
|
|
grandchild.add_middleware(PromptTracingMiddleware("grandchild", calls))
|
|
|
|
child = FastMCP("Child")
|
|
child.mount(grandchild, namespace="gc")
|
|
child.add_middleware(PromptTracingMiddleware("child", calls))
|
|
|
|
parent = FastMCP("Parent")
|
|
parent.mount(child, namespace="c")
|
|
parent.add_middleware(PromptTracingMiddleware("parent", calls))
|
|
|
|
async with Client(parent) as client:
|
|
task = await client.get_prompt("c_gc_greet", {"name": "World"}, task=True)
|
|
result = await task.result()
|
|
assert result.messages[0].content.text == "Hello, World!"
|
|
|
|
# Middleware runs during task submission, function in Docket
|
|
assert calls == [
|
|
"parent:before",
|
|
"child:before",
|
|
"grandchild:before",
|
|
"grandchild:after",
|
|
"child:after",
|
|
"parent:after",
|
|
"grandchild:prompt",
|
|
]
|
|
|
|
@pytest.mark.xfail(
|
|
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
|
|
"ReadResourceRequestParams; prompt/resource task submission is not "
|
|
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
|
|
strict=True,
|
|
)
|
|
async def test_resource_template_middleware_runs_with_background_task(self):
|
|
"""Middleware runs at all levels for resource template tasks."""
|
|
calls: list[str] = []
|
|
|
|
grandchild = FastMCP("Grandchild")
|
|
|
|
@grandchild.resource("item://{id}", task=True)
|
|
async def get_item(id: str) -> str:
|
|
calls.append("grandchild:template")
|
|
return f"item-{id}"
|
|
|
|
grandchild.add_middleware(ResourceTracingMiddleware("grandchild", calls))
|
|
|
|
child = FastMCP("Child")
|
|
child.mount(grandchild, namespace="gc")
|
|
child.add_middleware(ResourceTracingMiddleware("child", calls))
|
|
|
|
parent = FastMCP("Parent")
|
|
parent.mount(child, namespace="c")
|
|
parent.add_middleware(ResourceTracingMiddleware("parent", calls))
|
|
|
|
async with Client(parent) as client:
|
|
task = await client.read_resource("item://c/gc/42", task=True)
|
|
result = await task.result()
|
|
assert result[0].text == "item-42"
|
|
|
|
# Middleware runs during task submission, function in Docket
|
|
assert calls == [
|
|
"parent:before",
|
|
"child:before",
|
|
"grandchild:before",
|
|
"grandchild:after",
|
|
"child:after",
|
|
"parent:after",
|
|
"grandchild:template",
|
|
]
|
|
|
|
|
|
class TestMountedTasksWithTaskMetaParameter:
|
|
"""Test mounted components called directly with task_meta parameter.
|
|
|
|
These tests verify the programmatic API where server.call_tool() or
|
|
server.read_resource() is called with an explicit task_meta parameter,
|
|
as opposed to using the Client with task=True.
|
|
|
|
Direct server calls require a running server context, so we use an outer
|
|
tool that makes the direct call internally.
|
|
"""
|
|
|
|
async def test_mounted_tool_with_task_meta_creates_task(self):
|
|
"""Mounted tool called with task_meta returns CreateTaskResult."""
|
|
from fastmcp.server.tasks.config import TaskMeta
|
|
|
|
child = FastMCP("Child")
|
|
|
|
@child.tool(task=True)
|
|
async def add(a: int, b: int) -> int:
|
|
return a + b
|
|
|
|
parent = FastMCP("Parent")
|
|
parent.mount(child, namespace="child")
|
|
|
|
@parent.tool
|
|
async def outer() -> str:
|
|
# Direct call with task_meta from within server context
|
|
result = await parent.call_tool(
|
|
"child_add", {"a": 2, "b": 3}, task_meta=TaskMeta(ttl=300)
|
|
)
|
|
return f"task:{result.task.task_id}"
|
|
|
|
async with Client(parent) as client:
|
|
result = await client.call_tool("outer", {})
|
|
assert "task:" in str(result)
|
|
|
|
async def test_mounted_resource_with_task_meta_creates_task(self):
|
|
"""Mounted resource called with task_meta returns CreateTaskResult."""
|
|
from fastmcp.server.tasks.config import TaskMeta
|
|
|
|
child = FastMCP("Child")
|
|
|
|
@child.resource("data://info", task=True)
|
|
async def get_info() -> str:
|
|
return "child info"
|
|
|
|
parent = FastMCP("Parent")
|
|
parent.mount(child, namespace="child")
|
|
|
|
@parent.tool
|
|
async def outer() -> str:
|
|
result = await parent.read_resource(
|
|
"data://child/info", task_meta=TaskMeta(ttl=300)
|
|
)
|
|
return f"task:{result.task.task_id}"
|
|
|
|
async with Client(parent) as client:
|
|
result = await client.call_tool("outer", {})
|
|
assert "task:" in str(result)
|
|
|
|
async def test_mounted_template_with_task_meta_creates_task(self):
|
|
"""Mounted resource template with task_meta returns CreateTaskResult."""
|
|
from fastmcp.server.tasks.config import TaskMeta
|
|
|
|
child = FastMCP("Child")
|
|
|
|
@child.resource("item://{id}", task=True)
|
|
async def get_item(id: str) -> str:
|
|
return f"item-{id}"
|
|
|
|
parent = FastMCP("Parent")
|
|
parent.mount(child, namespace="child")
|
|
|
|
@parent.tool
|
|
async def outer() -> str:
|
|
result = await parent.read_resource(
|
|
"item://child/42", task_meta=TaskMeta(ttl=300)
|
|
)
|
|
return f"task:{result.task.task_id}"
|
|
|
|
async with Client(parent) as client:
|
|
result = await client.call_tool("outer", {})
|
|
assert "task:" in str(result)
|
|
|
|
async def test_deeply_nested_tool_with_task_meta(self):
|
|
"""Three-level nested tool works with task_meta."""
|
|
from fastmcp.server.tasks.config import TaskMeta
|
|
|
|
grandchild = FastMCP("Grandchild")
|
|
|
|
@grandchild.tool(task=True)
|
|
async def compute(n: int) -> int:
|
|
return n * 3
|
|
|
|
child = FastMCP("Child")
|
|
child.mount(grandchild, namespace="gc")
|
|
|
|
parent = FastMCP("Parent")
|
|
parent.mount(child, namespace="c")
|
|
|
|
@parent.tool
|
|
async def outer() -> str:
|
|
result = await parent.call_tool(
|
|
"c_gc_compute", {"n": 7}, task_meta=TaskMeta(ttl=300)
|
|
)
|
|
return f"task:{result.task.task_id}"
|
|
|
|
async with Client(parent) as client:
|
|
result = await client.call_tool("outer", {})
|
|
assert "task:" in str(result)
|
|
|
|
async def test_deeply_nested_template_with_task_meta(self):
|
|
"""Three-level nested template works with task_meta."""
|
|
from fastmcp.server.tasks.config import TaskMeta
|
|
|
|
grandchild = FastMCP("Grandchild")
|
|
|
|
@grandchild.resource("doc://{name}", task=True)
|
|
async def get_doc(name: str) -> str:
|
|
return f"doc: {name}"
|
|
|
|
child = FastMCP("Child")
|
|
child.mount(grandchild, namespace="gc")
|
|
|
|
parent = FastMCP("Parent")
|
|
parent.mount(child, namespace="c")
|
|
|
|
@parent.tool
|
|
async def outer() -> str:
|
|
result = await parent.read_resource(
|
|
"doc://c/gc/readme", task_meta=TaskMeta(ttl=300)
|
|
)
|
|
return f"task:{result.task.task_id}"
|
|
|
|
async with Client(parent) as client:
|
|
result = await client.call_tool("outer", {})
|
|
assert "task:" in str(result)
|
|
|
|
async def test_mounted_prompt_with_task_meta_creates_task(self):
|
|
"""Mounted prompt called with task_meta returns CreateTaskResult."""
|
|
from fastmcp.server.tasks.config import TaskMeta
|
|
|
|
child = FastMCP("Child")
|
|
|
|
@child.prompt(task=True)
|
|
async def greet(name: str) -> str:
|
|
return f"Hello, {name}!"
|
|
|
|
parent = FastMCP("Parent")
|
|
parent.mount(child, namespace="child")
|
|
|
|
@parent.tool
|
|
async def outer() -> str:
|
|
result = await parent.render_prompt(
|
|
"child_greet", {"name": "World"}, task_meta=TaskMeta(ttl=300)
|
|
)
|
|
return f"task:{result.task.task_id}"
|
|
|
|
async with Client(parent) as client:
|
|
result = await client.call_tool("outer", {})
|
|
assert "task:" in str(result)
|
|
|
|
async def test_deeply_nested_prompt_with_task_meta(self):
|
|
"""Three-level nested prompt works with task_meta."""
|
|
from fastmcp.server.tasks.config import TaskMeta
|
|
|
|
grandchild = FastMCP("Grandchild")
|
|
|
|
@grandchild.prompt(task=True)
|
|
async def describe(topic: str) -> str:
|
|
return f"Information about {topic}"
|
|
|
|
child = FastMCP("Child")
|
|
child.mount(grandchild, namespace="gc")
|
|
|
|
parent = FastMCP("Parent")
|
|
parent.mount(child, namespace="c")
|
|
|
|
@parent.tool
|
|
async def outer() -> str:
|
|
result = await parent.render_prompt(
|
|
"c_gc_describe", {"topic": "FastMCP"}, task_meta=TaskMeta(ttl=300)
|
|
)
|
|
return f"task:{result.task.task_id}"
|
|
|
|
async with Client(parent) as client:
|
|
result = await client.call_tool("outer", {})
|
|
assert "task:" in str(result)
|