mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 02:10:38 +02:00
Add TaskConfig for SEP-1686 execution modes
Expose the full MCP task execution modes (forbidden/optional/required) via TaskConfig instead of just boolean task=True/False.
This commit is contained in:
parent
8771f290bd
commit
a231ea4c3c
19 changed files with 854 additions and 263 deletions
|
|
@ -132,25 +132,84 @@ async def test_result_and_await_share_cache():
|
|||
assert id(result_via_method) == id(result_via_await)
|
||||
|
||||
|
||||
async def test_immediate_task_caches_result():
|
||||
"""Immediate tasks (graceful degradation) also cache results."""
|
||||
call_count = 0
|
||||
async def test_forbidden_mode_tool_caches_error_result():
|
||||
"""Tools with task=False (mode=forbidden) cache error results."""
|
||||
mcp = FastMCP("test")
|
||||
|
||||
# Tool with task=False - will execute immediately
|
||||
@mcp.tool(task=False)
|
||||
async def non_task_tool() -> int:
|
||||
return 1
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Request as task, but mode="forbidden" will reject with error
|
||||
task = await client.call_tool("non_task_tool", task=True)
|
||||
|
||||
# Should be immediate (error returned immediately)
|
||||
assert task.returned_immediately
|
||||
|
||||
result1 = await task.result()
|
||||
result2 = await task.result()
|
||||
result3 = await task.result()
|
||||
|
||||
# All should return cached error
|
||||
assert result1.is_error
|
||||
assert "does not support task-augmented execution" in str(result1)
|
||||
|
||||
# Verify they're the same object (cached)
|
||||
assert result1 is result2 is result3
|
||||
|
||||
|
||||
async def test_forbidden_mode_prompt_raises_error():
|
||||
"""Prompts with task=False (mode=forbidden) raise error."""
|
||||
import pytest
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
mcp = FastMCP("test")
|
||||
|
||||
@mcp.prompt(task=False)
|
||||
async def non_task_prompt() -> str:
|
||||
return "Immediate"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Prompts with mode="forbidden" raise McpError when called with task=True
|
||||
with pytest.raises(McpError):
|
||||
await client.get_prompt("non_task_prompt", task=True)
|
||||
|
||||
|
||||
async def test_forbidden_mode_resource_raises_error():
|
||||
"""Resources with task=False (mode=forbidden) raise error."""
|
||||
import pytest
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
mcp = FastMCP("test")
|
||||
|
||||
@mcp.resource("file://immediate.txt", task=False)
|
||||
async def non_task_resource() -> str:
|
||||
return "Immediate"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Resources with mode="forbidden" raise McpError when called with task=True
|
||||
with pytest.raises(McpError):
|
||||
await client.read_resource("file://immediate.txt", task=True)
|
||||
|
||||
|
||||
async def test_immediate_task_caches_result():
|
||||
"""Immediate tasks (optional mode called without background) cache results."""
|
||||
call_count = 0
|
||||
mcp = FastMCP("test", tasks=True)
|
||||
|
||||
# Tool with task=True (optional mode) - but without docket will execute immediately
|
||||
@mcp.tool(task=True)
|
||||
async def task_tool() -> int:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return call_count
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Request as task, but server will execute immediately
|
||||
task = await client.call_tool("non_task_tool", task=True)
|
||||
|
||||
# Should be immediate (graceful degradation)
|
||||
assert task.returned_immediately
|
||||
# Call with task=True
|
||||
task = await client.call_tool("task_tool", task=True)
|
||||
|
||||
# Get result multiple times
|
||||
result1 = await task.result()
|
||||
result2 = await task.result()
|
||||
result3 = await task.result()
|
||||
|
|
@ -164,56 +223,6 @@ async def test_immediate_task_caches_result():
|
|||
assert result1 is result2 is result3
|
||||
|
||||
|
||||
async def test_immediate_prompt_task_caches_result():
|
||||
"""Immediate prompt tasks cache results."""
|
||||
call_count = 0
|
||||
mcp = FastMCP("test")
|
||||
|
||||
@mcp.prompt(task=False)
|
||||
async def non_task_prompt() -> str:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return f"Immediate: {call_count}"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
task = await client.get_prompt("non_task_prompt", task=True)
|
||||
|
||||
# Should be immediate
|
||||
assert task.returned_immediately
|
||||
|
||||
result1 = await task.result()
|
||||
result2 = await task.result()
|
||||
|
||||
# Verify caching
|
||||
assert result1 is result2
|
||||
assert result1.messages[0].content.text == "Immediate: 1"
|
||||
|
||||
|
||||
async def test_immediate_resource_task_caches_result():
|
||||
"""Immediate resource tasks cache results."""
|
||||
call_count = 0
|
||||
mcp = FastMCP("test")
|
||||
|
||||
@mcp.resource("file://immediate.txt", task=False)
|
||||
async def non_task_resource() -> str:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return f"Immediate: {call_count}"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
task = await client.read_resource("file://immediate.txt", task=True)
|
||||
|
||||
# Should be immediate
|
||||
assert task.returned_immediately
|
||||
|
||||
result1 = await task.result()
|
||||
result2 = await task.result()
|
||||
|
||||
# Verify caching
|
||||
assert result1 is result2
|
||||
assert result1[0].text == "Immediate: 1"
|
||||
|
||||
|
||||
async def test_cache_persists_across_mixed_access_patterns():
|
||||
"""Cache works correctly when mixing result() and await."""
|
||||
mcp = FastMCP("test")
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ async def test_server_tasks_true_defaults_all_components():
|
|||
|
||||
|
||||
async def test_server_tasks_false_defaults_all_components():
|
||||
"""Server with tasks=False makes all components default to NOT supporting tasks."""
|
||||
"""Server with tasks=False makes all components default to mode=forbidden."""
|
||||
import pytest
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
mcp = FastMCP("test", tasks=False)
|
||||
|
||||
@mcp.tool()
|
||||
|
|
@ -65,17 +68,20 @@ async def test_server_tasks_false_defaults_all_components():
|
|||
return "resource result"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Tool should execute immediately (graceful degradation)
|
||||
# Tool with mode="forbidden" returns error when called with task=True
|
||||
tool_task = await client.call_tool("my_tool", task=True)
|
||||
assert tool_task.returned_immediately
|
||||
result = await tool_task.result()
|
||||
assert result.is_error
|
||||
assert "does not support task-augmented execution" in str(result)
|
||||
|
||||
# Prompt should execute immediately (graceful degradation)
|
||||
prompt_task = await client.get_prompt("my_prompt", task=True)
|
||||
assert prompt_task.returned_immediately
|
||||
# Prompt with mode="forbidden" raises McpError when called with task=True
|
||||
with pytest.raises(McpError):
|
||||
await client.get_prompt("my_prompt", task=True)
|
||||
|
||||
# Resource should execute immediately (graceful degradation)
|
||||
resource_task = await client.read_resource("test://resource", task=True)
|
||||
assert resource_task.returned_immediately
|
||||
# Resource with mode="forbidden" raises McpError when called with task=True
|
||||
with pytest.raises(McpError):
|
||||
await client.read_resource("test://resource", task=True)
|
||||
|
||||
|
||||
async def test_server_tasks_none_uses_settings():
|
||||
|
|
@ -102,9 +108,14 @@ async def test_server_tasks_none_uses_settings():
|
|||
return "tool result"
|
||||
|
||||
async with Client(mcp2) as client:
|
||||
# Tool should execute immediately (from settings)
|
||||
# When enable_tasks=False, server doesn't advertise task capabilities.
|
||||
# Client's task=True is ignored because server doesn't support tasks.
|
||||
# Tool executes synchronously and succeeds.
|
||||
tool_task = await client.call_tool("my_tool2", task=True)
|
||||
assert tool_task.returned_immediately
|
||||
result = await tool_task.result()
|
||||
# Tool should execute successfully (synchronously)
|
||||
assert "tool result" in str(result)
|
||||
|
||||
|
||||
async def test_component_explicit_false_overrides_server_true():
|
||||
|
|
@ -126,9 +137,12 @@ async def test_component_explicit_false_overrides_server_true():
|
|||
assert "no_task_tool" not in docket.tasks # task=False means not registered
|
||||
assert "default_tool" in docket.tasks # Inherits tasks=True
|
||||
|
||||
# Explicit False should execute immediately despite server default
|
||||
# Explicit False (mode="forbidden") returns error when called with task=True
|
||||
no_task = await client.call_tool("no_task_tool", task=True)
|
||||
assert no_task.returned_immediately
|
||||
result = await no_task.result()
|
||||
assert result.is_error
|
||||
assert "does not support task-augmented execution" in str(result)
|
||||
|
||||
# Default should support background execution
|
||||
default_task = await client.call_tool("default_tool", task=True)
|
||||
|
|
@ -158,13 +172,18 @@ async def test_component_explicit_true_overrides_server_false():
|
|||
task = await client.call_tool("task_tool", task=True)
|
||||
assert not task.returned_immediately
|
||||
|
||||
# Default should execute immediately
|
||||
# Default (mode="forbidden") returns error when called with task=True
|
||||
default = await client.call_tool("default_tool", task=True)
|
||||
assert default.returned_immediately
|
||||
result = await default.result()
|
||||
assert result.is_error
|
||||
|
||||
|
||||
async def test_mixed_explicit_and_inherited():
|
||||
"""Mix of explicit True/False/None on different components."""
|
||||
import pytest
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
mcp = FastMCP("test", tasks=True) # Server default is True
|
||||
|
||||
@mcp.tool()
|
||||
|
|
@ -216,17 +235,19 @@ async def test_mixed_explicit_and_inherited():
|
|||
explicit_true = await client.call_tool("explicit_true_tool", task=True)
|
||||
assert not explicit_true.returned_immediately
|
||||
|
||||
# Explicit False (mode="forbidden") returns error
|
||||
explicit_false = await client.call_tool("explicit_false_tool", task=True)
|
||||
assert explicit_false.returned_immediately
|
||||
result = await explicit_false.result()
|
||||
assert result.is_error
|
||||
|
||||
# Prompts
|
||||
inherited_prompt_task = await client.get_prompt("inherited_prompt", task=True)
|
||||
assert not inherited_prompt_task.returned_immediately
|
||||
|
||||
explicit_false_prompt_task = await client.get_prompt(
|
||||
"explicit_false_prompt", task=True
|
||||
)
|
||||
assert explicit_false_prompt_task.returned_immediately
|
||||
# Explicit False prompt (mode="forbidden") raises McpError
|
||||
with pytest.raises(McpError):
|
||||
await client.get_prompt("explicit_false_prompt", task=True)
|
||||
|
||||
# Resources
|
||||
inherited_resource_task = await client.read_resource(
|
||||
|
|
@ -234,10 +255,9 @@ async def test_mixed_explicit_and_inherited():
|
|||
)
|
||||
assert not inherited_resource_task.returned_immediately
|
||||
|
||||
explicit_false_resource_task = await client.read_resource(
|
||||
"test://explicit_false", task=True
|
||||
)
|
||||
assert explicit_false_resource_task.returned_immediately
|
||||
# Explicit False resource (mode="forbidden") raises McpError
|
||||
with pytest.raises(McpError):
|
||||
await client.read_resource("test://explicit_false", task=True)
|
||||
|
||||
|
||||
async def test_server_tasks_parameter_sets_component_defaults():
|
||||
|
|
@ -264,9 +284,11 @@ async def test_server_tasks_parameter_sets_component_defaults():
|
|||
return "tool result"
|
||||
|
||||
async with Client(mcp2) as client:
|
||||
# Tool inherits tasks=False from server (graceful degradation)
|
||||
# Tool inherits tasks=False (mode="forbidden") - returns error
|
||||
tool_task = await client.call_tool("tool_inherits_false", task=True)
|
||||
assert tool_task.returned_immediately
|
||||
result = await tool_task.result()
|
||||
assert result.is_error
|
||||
|
||||
|
||||
async def test_resource_template_inherits_server_tasks_default():
|
||||
|
|
@ -285,6 +307,9 @@ async def test_resource_template_inherits_server_tasks_default():
|
|||
|
||||
async def test_multiple_components_same_name_different_tasks():
|
||||
"""Different component types with same name can have different task settings."""
|
||||
import pytest
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
mcp = FastMCP("test", tasks=False)
|
||||
|
||||
@mcp.tool(task=True)
|
||||
|
|
@ -300,6 +325,6 @@ async def test_multiple_components_same_name_different_tasks():
|
|||
tool_task = await client.call_tool("shared_name", task=True)
|
||||
assert not tool_task.returned_immediately
|
||||
|
||||
# Prompt inheriting False should execute immediately
|
||||
prompt_task = await client.get_prompt("shared_name_prompt", task=True)
|
||||
assert prompt_task.returned_immediately
|
||||
# Prompt inheriting False (mode="forbidden") raises McpError
|
||||
with pytest.raises(McpError):
|
||||
await client.get_prompt("shared_name_prompt", task=True)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ async def test_sync_tool_with_explicit_task_true_raises():
|
|||
"""Sync tool with task=True raises ValueError."""
|
||||
mcp = FastMCP("test")
|
||||
|
||||
with pytest.raises(ValueError, match="uses a sync function but has task=True"):
|
||||
with pytest.raises(
|
||||
ValueError, match="uses a sync function but has task execution enabled"
|
||||
):
|
||||
|
||||
@mcp.tool(task=True)
|
||||
def sync_tool(x: int) -> int:
|
||||
|
|
@ -29,7 +31,9 @@ async def test_sync_tool_with_inherited_task_true_raises():
|
|||
"""Sync tool inheriting task=True from server raises ValueError."""
|
||||
mcp = FastMCP("test", tasks=True)
|
||||
|
||||
with pytest.raises(ValueError, match="uses a sync function but has task=True"):
|
||||
with pytest.raises(
|
||||
ValueError, match="uses a sync function but has task execution enabled"
|
||||
):
|
||||
|
||||
@mcp.tool() # Inherits task=True from server
|
||||
def sync_tool(x: int) -> int:
|
||||
|
|
@ -41,7 +45,9 @@ 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=True"):
|
||||
with pytest.raises(
|
||||
ValueError, match="uses a sync function but has task execution enabled"
|
||||
):
|
||||
|
||||
@mcp.prompt(task=True)
|
||||
def sync_prompt() -> str:
|
||||
|
|
@ -53,7 +59,9 @@ 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=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:
|
||||
|
|
@ -65,7 +73,9 @@ 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=True"):
|
||||
with pytest.raises(
|
||||
ValueError, match="uses a sync function but has task execution enabled"
|
||||
):
|
||||
|
||||
@mcp.resource("test://sync", task=True)
|
||||
def sync_resource() -> str:
|
||||
|
|
@ -77,7 +87,9 @@ 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=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:
|
||||
|
|
@ -94,10 +106,10 @@ async def test_async_tool_with_task_true_remains_enabled():
|
|||
"""An async tool."""
|
||||
return x * 2
|
||||
|
||||
# Tool should have task=True and be a FunctionTool
|
||||
# Tool should have task mode="optional" and be a FunctionTool
|
||||
tool = await mcp.get_tool("async_tool")
|
||||
assert isinstance(tool, FunctionTool)
|
||||
assert tool.task is True
|
||||
assert tool.task_config.mode == "optional"
|
||||
|
||||
|
||||
async def test_async_prompt_with_task_true_remains_enabled():
|
||||
|
|
@ -109,10 +121,10 @@ async def test_async_prompt_with_task_true_remains_enabled():
|
|||
"""An async prompt."""
|
||||
return "Hello"
|
||||
|
||||
# Prompt should have task=True and be a FunctionPrompt
|
||||
# Prompt should have task mode="optional" and be a FunctionPrompt
|
||||
prompt = await mcp.get_prompt("async_prompt")
|
||||
assert isinstance(prompt, FunctionPrompt)
|
||||
assert prompt.task is True
|
||||
assert prompt.task_config.mode == "optional"
|
||||
|
||||
|
||||
async def test_async_resource_with_task_true_remains_enabled():
|
||||
|
|
@ -124,10 +136,10 @@ async def test_async_resource_with_task_true_remains_enabled():
|
|||
"""An async resource."""
|
||||
return "data"
|
||||
|
||||
# Resource should have task=True and be a FunctionResource
|
||||
# Resource should have task mode="optional" and be a FunctionResource
|
||||
resource = await mcp._resource_manager.get_resource("test://async")
|
||||
assert isinstance(resource, FunctionResource)
|
||||
assert resource.task is True
|
||||
assert resource.task_config.mode == "optional"
|
||||
|
||||
|
||||
async def test_sync_tool_with_task_false_works():
|
||||
|
|
@ -141,7 +153,7 @@ async def test_sync_tool_with_task_false_works():
|
|||
|
||||
tool = await mcp.get_tool("sync_tool")
|
||||
assert isinstance(tool, FunctionTool)
|
||||
assert tool.task is False
|
||||
assert tool.task_config.mode == "forbidden"
|
||||
|
||||
|
||||
async def test_sync_prompt_with_task_false_works():
|
||||
|
|
@ -155,7 +167,7 @@ async def test_sync_prompt_with_task_false_works():
|
|||
|
||||
prompt = await mcp.get_prompt("sync_prompt")
|
||||
assert isinstance(prompt, FunctionPrompt)
|
||||
assert prompt.task is False
|
||||
assert prompt.task_config.mode == "forbidden"
|
||||
|
||||
|
||||
async def test_sync_resource_with_task_false_works():
|
||||
|
|
@ -169,7 +181,7 @@ async def test_sync_resource_with_task_false_works():
|
|||
|
||||
resource = await mcp._resource_manager.get_resource("test://sync")
|
||||
assert isinstance(resource, FunctionResource)
|
||||
assert resource.task is False
|
||||
assert resource.task_config.mode == "forbidden"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -187,7 +199,7 @@ async def test_async_callable_class_tool_with_task_true_works():
|
|||
|
||||
# Callable classes use Tool.from_function() directly
|
||||
tool = Tool.from_function(AsyncCallableTool(), task=True)
|
||||
assert tool.task is True
|
||||
assert tool.task_config.mode == "optional"
|
||||
|
||||
|
||||
async def test_async_callable_class_prompt_with_task_true_works():
|
||||
|
|
@ -200,7 +212,7 @@ async def test_async_callable_class_prompt_with_task_true_works():
|
|||
|
||||
# Callable classes use Prompt.from_function() directly
|
||||
prompt = Prompt.from_function(AsyncCallablePrompt(), task=True)
|
||||
assert prompt.task is True
|
||||
assert prompt.task_config.mode == "optional"
|
||||
|
||||
|
||||
async def test_sync_callable_class_tool_with_task_true_raises():
|
||||
|
|
@ -211,5 +223,7 @@ async def test_sync_callable_class_tool_with_task_true_raises():
|
|||
def __call__(self, x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
with pytest.raises(ValueError, match="uses a sync function but has task=True"):
|
||||
with pytest.raises(
|
||||
ValueError, match="uses a sync function but has task execution enabled"
|
||||
):
|
||||
Tool.from_function(SyncCallableTool(), task=True)
|
||||
|
|
|
|||
349
tests/server/tasks/test_task_config_modes.py
Normal file
349
tests/server/tasks/test_task_config_modes.py
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
"""Tests for TaskConfig mode enforcement (SEP-1686).
|
||||
|
||||
Tests that the server correctly enforces task execution modes:
|
||||
- "forbidden": No task support, error if client requests task
|
||||
- "optional": Supports both sync and task execution
|
||||
- "required": Requires task execution, error if client doesn't request task
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP, TaskConfig
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
|
||||
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._tool_manager.get_tool("my_tool")
|
||||
assert tool is not None
|
||||
assert tool.task_config.mode == "optional" # type: ignore[attr-defined]
|
||||
|
||||
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._tool_manager.get_tool("my_tool")
|
||||
assert tool is not None
|
||||
assert tool.task_config.mode == "forbidden" # type: ignore[attr-defined]
|
||||
|
||||
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._tool_manager.get_tool("my_tool")
|
||||
assert tool is not None
|
||||
assert tool.task_config.mode == "required" # type: ignore[attr-defined]
|
||||
|
||||
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._tool_manager.get_tool("my_tool_sync")
|
||||
assert tool is not None
|
||||
assert tool.task_config.mode == "forbidden" # type: ignore[attr-defined]
|
||||
|
||||
# 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._tool_manager.get_tool("my_tool_async")
|
||||
assert tool2 is not None
|
||||
assert tool2.task_config.mode == "optional" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
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 returns error when called without task metadata."""
|
||||
async with Client(server) as client:
|
||||
# The server returns isError=True, which the client converts to ToolError
|
||||
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.shared.exceptions import McpError
|
||||
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.shared.exceptions import McpError
|
||||
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 "forbidden message" in str(result.messages[0].content) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
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 tool.execution is not None
|
||||
assert tool.execution.taskSupport == "optional" # type: ignore[attr-defined]
|
||||
|
||||
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 tool.execution is not None
|
||||
assert tool.execution.taskSupport == "required" # type: ignore[attr-defined]
|
||||
|
||||
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._tool_manager.get_tool("sync_tool")
|
||||
assert tool is not None
|
||||
assert tool.task_config.mode == "forbidden" # type: ignore[attr-defined]
|
||||
|
|
@ -69,20 +69,24 @@ async def test_prompt_task_executes_in_background(prompt_server):
|
|||
assert "comprehensive" in result.messages[0].content.text.lower()
|
||||
|
||||
|
||||
async def test_graceful_degradation_prompt_without_task_flag(prompt_server):
|
||||
"""Prompts with task=False execute synchronously even with task metadata."""
|
||||
async def test_forbidden_mode_prompt_rejects_task_calls(prompt_server):
|
||||
"""Prompts with task=False (mode=forbidden) reject task-augmented calls."""
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import METHOD_NOT_FOUND
|
||||
|
||||
@prompt_server.prompt(task=False) # Explicitly disable task support
|
||||
async def sync_only_prompt(topic: str) -> str:
|
||||
return f"Sync prompt: {topic}"
|
||||
|
||||
async with Client(prompt_server) as client:
|
||||
# Try to call with task metadata - should execute synchronously
|
||||
task = await client.get_prompt("sync_only_prompt", {"topic": "test"}, task=True)
|
||||
# Calling with task=True when task=False should raise McpError
|
||||
import pytest
|
||||
|
||||
# Should have executed immediately (graceful degradation)
|
||||
assert task.returned_immediately
|
||||
with pytest.raises(McpError) as exc_info:
|
||||
await client.get_prompt("sync_only_prompt", {"topic": "test"}, task=True)
|
||||
|
||||
# Can get result without waiting
|
||||
result = await task.result()
|
||||
assert "Sync prompt: test" in result.messages[0].content.text
|
||||
# New behavior: mode="forbidden" returns METHOD_NOT_FOUND error
|
||||
assert exc_info.value.error.code == METHOD_NOT_FOUND
|
||||
assert (
|
||||
"does not support task-augmented execution" in exc_info.value.error.message
|
||||
)
|
||||
|
|
|
|||
|
|
@ -84,22 +84,25 @@ async def test_resource_template_with_task(resource_server):
|
|||
assert '"userId": "123"' in result[0].text
|
||||
|
||||
|
||||
async def test_graceful_degradation_resource_without_task_flag(resource_server):
|
||||
"""Resources with task=False execute synchronously even with task metadata."""
|
||||
async def test_forbidden_mode_resource_rejects_task_calls(resource_server):
|
||||
"""Resources with task=False (mode=forbidden) reject task-augmented calls."""
|
||||
import pytest
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import METHOD_NOT_FOUND
|
||||
|
||||
@resource_server.resource(
|
||||
"file://sync.txt", task=False
|
||||
"file://sync.txt/", task=False
|
||||
) # Explicitly disable task support
|
||||
async def sync_only_resource() -> str:
|
||||
return "Sync content"
|
||||
|
||||
async with Client(resource_server) as client:
|
||||
# Try to call with task metadata - should execute synchronously
|
||||
task = await client.read_resource("file://sync.txt", task=True)
|
||||
# Calling with task=True when task=False should raise McpError
|
||||
with pytest.raises(McpError) as exc_info:
|
||||
await client.read_resource("file://sync.txt", task=True)
|
||||
|
||||
# Should have executed immediately (graceful degradation)
|
||||
assert task.returned_immediately
|
||||
|
||||
# Can get result without waiting
|
||||
result = await task.result()
|
||||
assert "Sync content" in result[0].text
|
||||
# New behavior: mode="forbidden" returns METHOD_NOT_FOUND error
|
||||
assert exc_info.value.error.code == METHOD_NOT_FOUND
|
||||
assert (
|
||||
"does not support task-augmented execution" in exc_info.value.error.message
|
||||
)
|
||||
|
|
|
|||
|
|
@ -88,16 +88,15 @@ async def test_tool_task_executes_in_background(tool_server):
|
|||
assert result.data == "completed"
|
||||
|
||||
|
||||
async def test_graceful_degradation_tool_without_task_flag(tool_server):
|
||||
"""Tools with task=False execute synchronously even with task metadata."""
|
||||
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:
|
||||
# Try to call with task metadata - server should execute synchronously
|
||||
# Calling with task=True when task=False should return error
|
||||
task = await client.call_tool("sync_only_tool", {"message": "test"}, task=True)
|
||||
assert task
|
||||
assert task.returned_immediately
|
||||
|
||||
result = await task.result()
|
||||
assert "Sync: test" in str(result)
|
||||
|
||||
status = await task.status()
|
||||
assert status.status == "completed"
|
||||
# New behavior: mode="forbidden" returns an error
|
||||
assert result.is_error
|
||||
assert "does not support task-augmented execution" in str(result)
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ async def test_tool_functionality_with_annotations():
|
|||
|
||||
|
||||
async def test_task_execution_auto_populated_for_task_enabled_tool():
|
||||
"""Test that execution.task is automatically set when tool has task=True."""
|
||||
"""Test that execution.taskSupport is automatically set when tool has task=True."""
|
||||
mcp = FastMCP("Test Server")
|
||||
|
||||
@mcp.tool(task=True)
|
||||
|
|
@ -235,7 +235,7 @@ async def test_task_execution_auto_populated_for_task_enabled_tool():
|
|||
assert len(tools_result) == 1
|
||||
assert tools_result[0].name == "background_tool"
|
||||
assert tools_result[0].execution is not None
|
||||
assert tools_result[0].execution.task == "optional"
|
||||
assert tools_result[0].execution.taskSupport == "optional" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
async def test_task_execution_omitted_for_task_disabled_tool():
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class TestToolFromFunction:
|
|||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
"task": False,
|
||||
"task": {"mode": "forbidden"},
|
||||
"fn": HasName("add"),
|
||||
}
|
||||
)
|
||||
|
|
@ -100,7 +100,7 @@ class TestToolFromFunction:
|
|||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
"task": False,
|
||||
"task": {"mode": "forbidden"},
|
||||
"fn": HasName("fetch_data"),
|
||||
}
|
||||
)
|
||||
|
|
@ -135,7 +135,7 @@ class TestToolFromFunction:
|
|||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
"task": False,
|
||||
"task": {"mode": "forbidden"},
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -169,7 +169,7 @@ class TestToolFromFunction:
|
|||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
"task": False,
|
||||
"task": {"mode": "forbidden"},
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -211,7 +211,7 @@ class TestToolFromFunction:
|
|||
"type": "object",
|
||||
},
|
||||
"output_schema": {"additionalProperties": True, "type": "object"},
|
||||
"task": False,
|
||||
"task": {"mode": "forbidden"},
|
||||
"fn": HasName("create_user"),
|
||||
}
|
||||
)
|
||||
|
|
@ -274,7 +274,7 @@ class TestToolFromFunction:
|
|||
"required": ["x"],
|
||||
"type": "object",
|
||||
},
|
||||
"task": False,
|
||||
"task": {"mode": "forbidden"},
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -307,7 +307,7 @@ class TestToolFromFunction:
|
|||
"required": ["_a", "_b"],
|
||||
"type": "object",
|
||||
},
|
||||
"task": False,
|
||||
"task": {"mode": "forbidden"},
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -362,7 +362,7 @@ class TestToolFromFunction:
|
|||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
"task": False,
|
||||
"task": {"mode": "forbidden"},
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue