fastmcp/tests/tasks/server/test_task_config.py
Jeremiah Lowin 7699deb99c
Pass the MCP conformance suite's draft and pending scenarios
Pin the suite, build out the fixture, and fix the protocol gaps it found.
2026-07-26 15:00:08 -04:00

341 lines
12 KiB
Python

"""Tests for TaskConfig (SEP-2663, tools only).
Tests for TaskConfig:
- Normalization of boolean task values to TaskConfig
- Sync-function validation
- Tool mode enforcement (forbidden, optional, required)
- Tool execution metadata (task_support in tools/list)
- Poll interval configuration
"""
from datetime import timedelta
import pytest
from fastmcp_tasks.models import (
MISSING_REQUIRED_CLIENT_CAPABILITY,
CreateTaskResult,
)
from mcp.shared.exceptions import MCPError
from mcp_types import ToolExecution
from fastmcp import FastMCP
from fastmcp.tools.base import Tool
from fastmcp.utilities.tasks import TaskConfig
from fastmcp.utilities.versions import VersionSpec
from fastmcp_tasks import TasksExtension
from tests.tasks.task_helpers import (
_opted_in_request,
auth_scope,
call_tool_without_optin,
running_task_server,
submit_task,
)
async def _opted_in_call(server: FastMCP, name: str, arguments: dict | None = None):
"""Run a `tools/call` WITH the tasks opt-in bound (used to prove sync paths)."""
with auth_scope(None), _opted_in_request(name, arguments or {}, None):
return await server.call_tool(name, arguments or {})
def test_docket_settings_load_from_dotenv(tmp_path, monkeypatch):
"""`FASTMCP_DOCKET_*` in a `.env` file configures the backend.
A distributed deployment that puts its Redis URL in `.env` must not silently
fall back to `memory://` — DocketSettings loads the same dotenv source as
core FastMCP settings.
"""
from fastmcp_tasks.settings import DocketSettings
(tmp_path / ".env").write_text("FASTMCP_DOCKET_URL=redis://dotenv-host:6379/2\n")
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("FASTMCP_DOCKET_URL", raising=False)
assert DocketSettings().url == "redis://dotenv-host:6379/2"
async def test_interceptor_tasks_the_requested_version_not_the_highest():
"""A versioned tools/call tasks the version the caller asked for.
Two versions share a name but differ in task mode: v1 is task-forbidden,
v2 is task-optional. A call targeting v1 (with the tasks opt-in) must run v1
synchronously — resolving the highest version instead would wrongly task v2.
"""
mcp = FastMCP("versioned-tasks")
mcp.add_extension(TasksExtension())
@mcp.tool(name="calc", version="1.0")
async def calc_v1() -> str:
return "v1-sync"
@mcp.tool(name="calc", version="2.0", task=True)
async def calc_v2() -> str:
return "v2"
async with running_task_server(mcp):
with auth_scope(None), _opted_in_request("calc", {}, None):
result = await mcp.call_tool("calc", {}, version=VersionSpec(eq="1.0"))
assert not isinstance(result, CreateTaskResult)
assert result.structured_content == {"result": "v1-sync"}
class TestTaskConfigNormalization:
"""Test that boolean task values normalize correctly to TaskConfig."""
async def test_task_true_normalizes_to_optional(self):
"""task=True should normalize to TaskConfig(mode='optional')."""
mcp = FastMCP("test", tasks=False) # Disable default task support
@mcp.tool(task=True)
async def my_tool() -> str:
return "ok"
tool = await mcp.get_tool("my_tool")
assert isinstance(tool, Tool)
assert tool.task_config.mode == "optional"
async def test_task_false_normalizes_to_forbidden(self):
"""task=False should normalize to TaskConfig(mode='forbidden')."""
mcp = FastMCP("test", tasks=False)
@mcp.tool(task=False)
async def my_tool() -> str:
return "ok"
tool = await mcp.get_tool("my_tool")
assert isinstance(tool, Tool)
assert tool.task_config.mode == "forbidden"
async def test_task_config_passed_directly(self):
"""TaskConfig should be preserved when passed directly."""
mcp = FastMCP("test", tasks=False)
@mcp.tool(task=TaskConfig(mode="required"))
async def my_tool() -> str:
return "ok"
tool = await mcp.get_tool("my_tool")
assert isinstance(tool, Tool)
assert tool.task_config.mode == "required"
async def test_default_task_inherits_server_default(self):
"""Default task value should inherit from server default."""
# Server with tasks disabled
mcp_no_tasks = FastMCP("test", tasks=False)
@mcp_no_tasks.tool()
def my_tool_sync() -> str:
return "ok"
tool = await mcp_no_tasks.get_tool("my_tool_sync")
assert isinstance(tool, Tool)
assert tool.task_config.mode == "forbidden"
# Server with tasks enabled
mcp_tasks = FastMCP("test", tasks=True)
@mcp_tasks.tool()
async def my_tool_async() -> str:
return "ok"
tool2 = await mcp_tasks.get_tool("my_tool_async")
assert isinstance(tool2, Tool)
assert tool2.task_config.mode == "optional"
class TestToolModeEnforcement:
"""Test mode enforcement for tools under the SEP-2663 interceptor."""
def _server(self) -> FastMCP:
mcp = FastMCP("test", tasks=False)
mcp.add_extension(TasksExtension())
@mcp.tool(task=TaskConfig(mode="required"))
async def required_tool() -> str:
return "required result"
@mcp.tool(task=TaskConfig(mode="forbidden"))
async def forbidden_tool() -> str:
return "forbidden result"
@mcp.tool(task=TaskConfig(mode="optional"))
async def optional_tool() -> str:
return "optional result"
return mcp
async def test_required_mode_without_opt_in_raises(self):
"""Required mode raises -32021 when called without a tasks opt-in."""
mcp = self._server()
async with running_task_server(mcp):
with pytest.raises(MCPError) as exc_info:
await call_tool_without_optin(mcp, "required_tool")
assert exc_info.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
async def test_required_mode_with_opt_in_tasks(self):
"""Required mode tasks when the caller opts in."""
mcp = self._server()
async with running_task_server(mcp):
created = await submit_task(mcp, "required_tool")
assert isinstance(created, CreateTaskResult)
async def test_forbidden_mode_never_tasks_even_with_opt_in(self):
"""Forbidden mode runs synchronously even when the caller opts in."""
mcp = self._server()
async with running_task_server(mcp):
result = await _opted_in_call(mcp, "forbidden_tool")
assert not isinstance(result, CreateTaskResult)
assert result.structured_content == {"result": "forbidden result"}
async def test_optional_mode_without_opt_in_runs_sync(self):
"""Optional mode runs synchronously without a tasks opt-in."""
mcp = self._server()
async with running_task_server(mcp):
result = await call_tool_without_optin(mcp, "optional_tool")
assert not isinstance(result, CreateTaskResult)
assert result.structured_content == {"result": "optional result"}
async def test_optional_mode_with_opt_in_tasks(self):
"""Optional mode tasks when the caller opts in."""
mcp = self._server()
async with running_task_server(mcp):
created = await submit_task(mcp, "optional_tool")
assert isinstance(created, CreateTaskResult)
class TestToolExecutionMetadata:
"""Test that ToolExecution.task_support is set correctly in tool metadata.
The tools/list payload is produced by ``Tool.to_mcp_tool()``; these tests
assert on that serialization directly, which is what a server advertises on
the wire. (The FastMCP client session does not yet surface ``execution`` back
to callers, so a client round-trip cannot observe it until Phase 4.)
"""
async def test_optional_tool_exposes_task_support(self):
"""Tools with mode=optional expose task_support='optional'."""
mcp = FastMCP("test", tasks=False)
mcp.add_extension(TasksExtension())
@mcp.tool(task=TaskConfig(mode="optional"))
async def my_tool() -> str:
return "ok"
tool = await mcp.get_tool("my_tool")
assert tool is not None
execution = tool.to_mcp_tool().execution
assert isinstance(execution, ToolExecution)
assert execution.task_support == "optional"
async def test_required_tool_exposes_task_support(self):
"""Tools with mode=required expose task_support='required'."""
mcp = FastMCP("test", tasks=False)
mcp.add_extension(TasksExtension())
@mcp.tool(task=TaskConfig(mode="required"))
async def my_tool() -> str:
return "ok"
tool = await mcp.get_tool("my_tool")
assert tool is not None
execution = tool.to_mcp_tool().execution
assert isinstance(execution, ToolExecution)
assert execution.task_support == "required"
async def test_forbidden_tool_has_no_execution(self):
"""Tools with mode=forbidden do not expose execution metadata."""
mcp = FastMCP("test", tasks=False)
mcp.add_extension(TasksExtension())
@mcp.tool(task=TaskConfig(mode="forbidden"))
async def my_tool() -> str:
return "ok"
tool = await mcp.get_tool("my_tool")
assert tool is not None
assert tool.to_mcp_tool().execution is None
class TestSyncFunctionValidation:
"""Test that sync functions cannot have task execution enabled."""
def test_sync_function_with_task_true_raises(self):
"""Sync functions should raise ValueError when task=True."""
mcp = FastMCP("test", tasks=False)
with pytest.raises(ValueError, match="sync function"):
@mcp.tool(task=True)
def sync_tool() -> str:
return "ok"
def test_sync_function_with_required_mode_raises(self):
"""Sync functions should raise ValueError with mode='required'."""
mcp = FastMCP("test", tasks=False)
with pytest.raises(ValueError, match="sync function"):
@mcp.tool(task=TaskConfig(mode="required"))
def sync_tool() -> str:
return "ok"
def test_sync_function_with_optional_mode_raises(self):
"""Sync functions should raise ValueError with mode='optional'."""
mcp = FastMCP("test", tasks=False)
with pytest.raises(ValueError, match="sync function"):
@mcp.tool(task=TaskConfig(mode="optional"))
def sync_tool() -> str:
return "ok"
async def test_sync_function_with_forbidden_mode_ok(self):
"""Sync functions should work fine with mode='forbidden'."""
mcp = FastMCP("test", tasks=False)
@mcp.tool(task=TaskConfig(mode="forbidden"))
def sync_tool() -> str:
return "ok"
tool = await mcp.get_tool("sync_tool")
assert isinstance(tool, Tool)
assert tool.task_config.mode == "forbidden"
class TestPollIntervalConfiguration:
"""Test poll_interval configuration in TaskConfig."""
async def test_default_poll_interval_is_5_seconds(self):
"""Default poll_interval should be 5 seconds."""
config = TaskConfig()
assert config.poll_interval == timedelta(seconds=5)
async def test_custom_poll_interval_preserved(self):
"""Custom poll_interval should be preserved in TaskConfig."""
config = TaskConfig(poll_interval=timedelta(seconds=10))
assert config.poll_interval == timedelta(seconds=10)
async def test_tool_inherits_poll_interval(self):
"""Tool should inherit poll_interval from TaskConfig."""
mcp = FastMCP("test", tasks=False)
@mcp.tool(task=TaskConfig(mode="optional", poll_interval=timedelta(seconds=2)))
async def my_tool() -> str:
return "ok"
tool = await mcp.get_tool("my_tool")
assert isinstance(tool, Tool)
assert tool.task_config.poll_interval == timedelta(seconds=2)
async def test_task_true_uses_default_poll_interval(self):
"""task=True should use default 5 second poll_interval."""
mcp = FastMCP("test", tasks=False)
@mcp.tool(task=True)
async def my_tool() -> str:
return "ok"
tool = await mcp.get_tool("my_tool")
assert isinstance(tool, Tool)
assert tool.task_config.poll_interval == timedelta(seconds=5)