Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:59 +08:00

244 lines
8.4 KiB
Python

"""
Tests for server-side tool task behavior.
Tests tool-specific task handling, parallel to test_task_prompts.py
and test_task_resources.py.
"""
import asyncio
import functools
import mcp_types
import pytest
from pydantic import BaseModel
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.messages import MessageHandler
from fastmcp.client.tasks import ToolTask
from fastmcp.exceptions import ToolError
from fastmcp.tools.function_tool import _resolve_param_hints
@pytest.fixture
async def tool_server():
"""Create a FastMCP server with task-enabled tools."""
mcp = FastMCP("tool-task-server")
@mcp.tool(task=True)
async def simple_tool(message: str) -> str:
"""A simple tool for testing."""
return f"Processed: {message}"
@mcp.tool(task=False)
async def sync_only_tool(message: str) -> str:
"""Tool with task=False."""
return f"Sync: {message}"
return mcp
class _Item(BaseModel):
value: str
async def test_task_tool_validates_model_arguments():
"""Model-typed args are coerced to model instances for task calls (#4349).
The synchronous path validates arguments through the function's
TypeAdapter, so a parameter typed as a Pydantic model arrives as a model
instance. The task path must coerce the same way rather than passing the
raw dict through to the function.
"""
mcp = FastMCP("tool-task-validation-server")
@mcp.tool(task=True)
async def inspect_items(item: _Item, items: list[_Item]) -> dict[str, str]:
return {"item": type(item).__name__, "element": type(items[0]).__name__}
arguments = {"item": {"value": "a"}, "items": [{"value": "b"}]}
expected = {"item": "_Item", "element": "_Item"}
async with Client(mcp) as client:
sync_result = await client.call_tool("inspect_items", arguments)
task = await client.call_tool("inspect_items", arguments, task=True)
task_result = await task.result()
assert sync_result.data == expected
assert task_result.data == expected
async def test_task_tool_invalid_arguments_fail_before_task_state():
"""Invalid task arguments are rejected before any task state is created.
Coercion runs up front in submit_to_docket, so a validation failure surfaces
before the task's Redis metadata and initial "working" status notification
are written. Otherwise an invalid input would orphan a task the client had
already observed via that notification.
"""
class _Recorder(MessageHandler):
def __init__(self):
super().__init__()
self.methods: list[str] = []
async def on_notification(self, message: mcp_types.ServerNotification) -> None:
self.methods.append(message.method)
server = FastMCP("tool-task-invalid-args-server")
@server.tool(task=True)
async def needs_item(item: _Item) -> str:
return item.value
recorder = _Recorder()
async with Client(server, message_handler=recorder) as client:
# `item` is missing its required `value` field.
task = await client.call_tool("needs_item", {"item": {}}, task=True)
assert task.returned_immediately
with pytest.raises(ToolError):
await task.result()
assert "notifications/tasks/status" not in recorder.methods
async def test_task_submission_honors_strict_input_validation():
"""Strict input validation applies to task submissions, not just sync calls.
With ``strict_input_validation=True``, a lax coercion like ``{"n": "1"}``
for an ``int`` parameter is rejected on the synchronous path. The task
submission path must reject it identically rather than silently coercing
and queueing it — otherwise ``task=True`` would bypass the strict flag.
"""
class _Recorder(MessageHandler):
def __init__(self):
super().__init__()
self.methods: list[str] = []
async def on_notification(self, message: mcp_types.ServerNotification) -> None:
self.methods.append(message.method)
server = FastMCP("strict-task-server", strict_input_validation=True)
@server.tool(task=True)
async def square(n: int) -> int:
return n * n
recorder = _Recorder()
async with Client(server, message_handler=recorder) as client:
# Sync path rejects the string-for-int coercion under strict validation.
with pytest.raises(ToolError):
await client.call_tool("square", {"n": "1"})
# Task path must reject it too, before any task state is created — so no
# status notification is emitted for the orphaned submission.
task = await client.call_tool("square", {"n": "1"}, task=True)
assert task.returned_immediately
with pytest.raises(ToolError):
await task.result()
assert "notifications/tasks/status" not in recorder.methods
async def test_task_submission_valid_argument_under_strict_validation():
"""A well-typed argument still submits fine when strict validation is on."""
server = FastMCP("strict-task-valid-server", strict_input_validation=True)
@server.tool(task=True)
async def square(n: int) -> int:
return n * n
async with Client(server) as client:
task = await client.call_tool("square", {"n": 4}, task=True)
assert not task.returned_immediately
result = await task.result()
assert result.data == 16
def test_resolve_param_hints_handles_partials():
"""Partials aren't introspectable by get_type_hints; resolve via the func.
Argument coercion must not raise for partial-wrapped callables — it should
resolve hints for the still-unbound parameters.
"""
async def base(prefix: str, items: list[_Item]) -> str:
return prefix
partial_fn = functools.partial(base, "bound")
hints = _resolve_param_hints(partial_fn)
assert hints["items"] == list[_Item]
async def test_synchronous_tool_call_unchanged(tool_server):
"""Tools without task metadata execute synchronously as before."""
async with Client(tool_server) as client:
# Regular call without task metadata
result = await client.call_tool("simple_tool", {"message": "hello"})
# Should execute immediately and return result
assert "Processed: hello" in str(result)
async def test_tool_with_task_metadata_returns_immediately(tool_server):
"""Tools with task metadata return immediately with ToolTask object."""
async with Client(tool_server) as client:
# Call with task metadata
task = await client.call_tool("simple_tool", {"message": "test"}, task=True)
assert task
assert not task.returned_immediately
assert isinstance(task, ToolTask)
assert isinstance(task.task_id, str)
assert len(task.task_id) > 0
async def test_tool_task_executes_in_background(tool_server):
"""Tool task is submitted to Docket and executes in background."""
execution_started = asyncio.Event()
execution_completed = asyncio.Event()
@tool_server.tool(task=True)
async def coordinated_tool() -> str:
"""Tool with coordination points."""
execution_started.set()
await execution_completed.wait()
return "completed"
async with Client(tool_server) as client:
task = await client.call_tool("coordinated_tool", task=True)
assert task
assert not task.returned_immediately
# Wait for execution to start
await asyncio.wait_for(execution_started.wait(), timeout=2.0)
# Task should still be working
status = await task.status()
assert status.status in ["working"]
# Signal completion
execution_completed.set()
await task.wait(timeout=2.0)
result = await task.result()
assert result.data == "completed"
async def test_forbidden_mode_tool_rejects_task_calls(tool_server):
"""Tools with task=False (mode=forbidden) reject task-augmented calls."""
async with Client(tool_server) as client:
# Calling with task=True when task=False should return error
task = await client.call_tool(
"sync_only_tool", {"message": "test"}, task=True, raise_on_error=False
)
assert task
assert task.returned_immediately
result = await task.result()
# New behavior: mode="forbidden" returns an error
assert result.is_error
assert "does not support task-augmented execution" in str(result)