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:
Jeremiah Lowin 2025-12-06 21:01:11 -05:00
commit a231ea4c3c
19 changed files with 854 additions and 263 deletions

View file

@ -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")

View file

@ -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)

View file

@ -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)

View 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]

View file

@ -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
)

View file

@ -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
)

View file

@ -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)

View file

@ -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():

View file

@ -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"},
}
)