fastmcp/tests/server/tasks/test_task_config.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

393 lines
15 KiB
Python

"""Tests for TaskConfig (SEP-1686).
Tests for TaskConfig:
- Mode enforcement (forbidden, optional, required)
- Poll interval configuration
"""
from datetime import timedelta
import pytest
from mcp.shared.exceptions import McpError
from mcp.types import TextContent, ToolExecution
from mcp.types import Tool as MCPTool
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.exceptions import ToolError
from fastmcp.server.tasks import TaskConfig
from fastmcp.tools.base import Tool
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."""
@pytest.fixture
def server(self):
"""Create server with tools in different modes."""
mcp = FastMCP("test", tasks=False)
@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"
@mcp.tool(task=TaskConfig(mode="optional"))
async def optional_tool() -> str:
"""Tool that supports both modes."""
return "optional result"
return mcp
async def test_required_mode_without_task_returns_error(self, server):
"""Required mode raises error when called without task metadata."""
async with Client(server) as client:
with pytest.raises(ToolError) as exc_info:
await client.call_tool("required_tool", {})
assert "requires task-augmented execution" in str(exc_info.value)
async def test_required_mode_with_task_succeeds(self, server):
"""Required mode succeeds when called with task metadata."""
async with Client(server) as client:
task = await client.call_tool("required_tool", {}, task=True)
assert task is not None
result = await task.result()
assert result.data == "required result"
async def test_forbidden_mode_with_task_returns_error(self, server):
"""Forbidden mode returns error when called with task metadata."""
async with Client(server) as client:
# Call with task=True should fail
task = await client.call_tool("forbidden_tool", {}, task=True)
assert task is not None
# The task should have returned immediately with an error
assert task.returned_immediately
result = await task.result()
# Check for error in the result
assert result.is_error
async def test_forbidden_mode_without_task_succeeds(self, server):
"""Forbidden mode succeeds when called without task metadata."""
async with Client(server) as client:
result = await client.call_tool("forbidden_tool", {})
assert "forbidden result" in str(result)
async def test_optional_mode_without_task_succeeds(self, server):
"""Optional mode succeeds when called without task metadata."""
async with Client(server) as client:
result = await client.call_tool("optional_tool", {})
assert "optional result" in str(result)
async def test_optional_mode_with_task_succeeds(self, server):
"""Optional mode succeeds when called with task metadata."""
async with Client(server) as client:
task = await client.call_tool("optional_tool", {}, task=True)
assert task is not None
result = await task.result()
assert result.data == "optional result"
class TestResourceModeEnforcement:
"""Test mode enforcement for resources."""
@pytest.fixture
def server(self):
"""Create server with resources in different modes."""
mcp = FastMCP("test", tasks=False)
@mcp.resource("resource://required", task=TaskConfig(mode="required"))
async def required_resource() -> str:
"""Resource that requires task execution."""
return "required content"
@mcp.resource("resource://forbidden", task=TaskConfig(mode="forbidden"))
async def forbidden_resource() -> str:
"""Resource that forbids task execution."""
return "forbidden content"
@mcp.resource("resource://optional", task=TaskConfig(mode="optional"))
async def optional_resource() -> str:
"""Resource that supports both modes."""
return "optional content"
return mcp
async def test_required_resource_without_task_returns_error(self, server):
"""Required mode returns error when read without task metadata."""
from mcp.types import METHOD_NOT_FOUND
async with Client(server) as client:
with pytest.raises(McpError) as exc_info:
await client.read_resource("resource://required")
assert exc_info.value.error.code == METHOD_NOT_FOUND
assert "requires task-augmented execution" in exc_info.value.error.message
async def test_required_resource_with_task_succeeds(self, server):
"""Required mode succeeds when read with task metadata."""
async with Client(server) as client:
task = await client.read_resource("resource://required", task=True)
assert task is not None
result = await task.result()
# Result is a list of resource contents
assert "required content" in str(result)
async def test_forbidden_resource_without_task_succeeds(self, server):
"""Forbidden mode succeeds when read without task metadata."""
async with Client(server) as client:
result = await client.read_resource("resource://forbidden")
assert "forbidden content" in str(result)
class TestPromptModeEnforcement:
"""Test mode enforcement for prompts."""
@pytest.fixture
def server(self):
"""Create server with prompts in different modes."""
mcp = FastMCP("test", tasks=False)
@mcp.prompt(task=TaskConfig(mode="required"))
async def required_prompt() -> str:
"""Prompt that requires task execution."""
return "required message"
@mcp.prompt(task=TaskConfig(mode="forbidden"))
async def forbidden_prompt() -> str:
"""Prompt that forbids task execution."""
return "forbidden message"
@mcp.prompt(task=TaskConfig(mode="optional"))
async def optional_prompt() -> str:
"""Prompt that supports both modes."""
return "optional message"
return mcp
async def test_required_prompt_without_task_returns_error(self, server):
"""Required mode returns error when called without task metadata."""
from mcp.types import METHOD_NOT_FOUND
async with Client(server) as client:
with pytest.raises(McpError) as exc_info:
await client.get_prompt("required_prompt")
assert exc_info.value.error.code == METHOD_NOT_FOUND
assert "requires task-augmented execution" in exc_info.value.error.message
async def test_required_prompt_with_task_succeeds(self, server):
"""Required mode succeeds when called with task metadata."""
async with Client(server) as client:
task = await client.get_prompt("required_prompt", task=True)
assert task is not None
result = await task.result()
# Result contains the prompt messages
assert "required message" in str(result)
async def test_forbidden_prompt_without_task_succeeds(self, server):
"""Forbidden mode succeeds when called without task metadata."""
async with Client(server) as client:
result = await client.get_prompt("forbidden_prompt")
assert isinstance(result.messages[0].content, TextContent)
assert "forbidden message" in str(result.messages[0].content)
class TestToolExecutionMetadata:
"""Test that ToolExecution.taskSupport is set correctly in tool metadata."""
async def test_optional_tool_exposes_task_support(self):
"""Tools with task enabled should expose taskSupport in metadata."""
mcp = FastMCP("test", tasks=False)
@mcp.tool(task=TaskConfig(mode="optional"))
async def my_tool() -> str:
return "ok"
async with Client(mcp) as client:
tools = await client.list_tools()
tool = next(t for t in tools if t.name == "my_tool")
assert isinstance(tool, MCPTool)
assert isinstance(tool.execution, ToolExecution)
assert tool.execution.taskSupport == "optional"
async def test_required_tool_exposes_task_support(self):
"""Tools with mode=required should expose taskSupport='required'."""
mcp = FastMCP("test", tasks=False)
@mcp.tool(task=TaskConfig(mode="required"))
async def my_tool() -> str:
return "ok"
async with Client(mcp) as client:
tools = await client.list_tools()
tool = next(t for t in tools if t.name == "my_tool")
assert isinstance(tool, MCPTool)
assert isinstance(tool.execution, ToolExecution)
assert tool.execution.taskSupport == "required"
async def test_forbidden_tool_has_no_execution(self):
"""Tools with mode=forbidden should not expose execution metadata."""
mcp = FastMCP("test", tasks=False)
@mcp.tool(task=TaskConfig(mode="forbidden"))
async def my_tool() -> str:
return "ok"
async with Client(mcp) as client:
tools = await client.list_tools()
tool = next(t for t in tools if t.name == "my_tool")
assert 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)