From a231ea4c3c5c837b6c47f8eb55bb6340efe9ead2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 6 Dec 2025 21:01:11 -0500 Subject: [PATCH 1/2] 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. --- docs/servers/tasks.mdx | 39 +- src/fastmcp/__init__.py | 2 + src/fastmcp/prompts/prompt.py | 33 +- src/fastmcp/resources/resource.py | 39 +- src/fastmcp/resources/template.py | 35 +- src/fastmcp/server/server.py | 178 ++++++--- src/fastmcp/server/tasks/__init__.py | 3 + src/fastmcp/server/tasks/config.py | 89 +++++ src/fastmcp/server/tasks/converters.py | 6 +- src/fastmcp/tools/tool.py | 52 ++- .../client/tasks/test_task_result_caching.py | 89 +++-- .../tasks/test_server_tasks_parameter.py | 71 ++-- .../tasks/test_sync_function_task_disabled.py | 50 ++- tests/server/tasks/test_task_config_modes.py | 349 ++++++++++++++++++ tests/server/tasks/test_task_prompts.py | 22 +- tests/server/tasks/test_task_resources.py | 27 +- tests/server/tasks/test_task_tools.py | 13 +- tests/server/test_tool_annotations.py | 4 +- tests/tools/test_tool.py | 16 +- 19 files changed, 854 insertions(+), 263 deletions(-) create mode 100644 src/fastmcp/server/tasks/config.py create mode 100644 tests/server/tasks/test_task_config_modes.py diff --git a/docs/servers/tasks.mdx b/docs/servers/tasks.mdx index 878cab4396..fcf1f75fb9 100644 --- a/docs/servers/tasks.mdx +++ b/docs/servers/tasks.mdx @@ -61,6 +61,41 @@ When a client requests background execution, the call returns immediately with a Background tasks require async functions. Attempting to use `task=True` with a sync function raises a `ValueError` at registration time. +## Execution Modes + +For fine-grained control over task execution behavior, use `TaskConfig` instead of the boolean shorthand. The MCP task protocol defines three execution modes: + +| Mode | Client calls without task | Client calls with task | +|------|--------------------------|------------------------| +| `"forbidden"` | Executes synchronously | Error: task not supported | +| `"optional"` | Executes synchronously | Executes as background task | +| `"required"` | Error: task required | Executes as background task | + +```python +from fastmcp import FastMCP, TaskConfig + +mcp = FastMCP("MyServer") + +# Supports both sync and background execution (default when task=True) +@mcp.tool(task=TaskConfig(mode="optional")) +async def flexible_task() -> str: + return "Works either way" + +# Requires background execution - errors if client doesn't request task +@mcp.tool(task=TaskConfig(mode="required")) +async def must_be_background() -> str: + return "Only runs as a background task" + +# No task support (default when task=False or omitted) +@mcp.tool(task=TaskConfig(mode="forbidden")) +async def sync_only() -> str: + return "Never runs as background task" +``` + +The boolean shortcuts map to these modes: +- `task=True` → `TaskConfig(mode="optional")` +- `task=False` → `TaskConfig(mode="forbidden")` + ### Server-Wide Default To enable background task support for all components by default, pass `tasks=True` to the constructor. Individual decorators can still override this with `task=False`. @@ -75,7 +110,9 @@ If your server defines any synchronous tools, resources, or prompts, you will ne ### Graceful Degradation -When a client requests background execution (`task=True` in the request) but the component doesn't support it (`task=False` on the decorator), FastMCP executes synchronously and returns the result inline. This follows the SEP-1686 specification for graceful degradation—clients can always request background execution without worrying about server capabilities. +When a client requests background execution but the component has `mode="forbidden"`, FastMCP executes synchronously and returns the result inline. This follows the SEP-1686 specification for graceful degradation—clients can always request background execution without worrying about server capabilities. + +Conversely, when a component has `mode="required"` but the client doesn't request background execution, FastMCP returns an error indicating that task execution is required. ### Configuration diff --git a/src/fastmcp/__init__.py b/src/fastmcp/__init__.py index 99cce018d3..9d5a85698f 100644 --- a/src/fastmcp/__init__.py +++ b/src/fastmcp/__init__.py @@ -14,6 +14,7 @@ from fastmcp.server.server import FastMCP from fastmcp.server.context import Context +from fastmcp.server.tasks.config import TaskConfig import fastmcp.server from fastmcp.client import Client @@ -31,6 +32,7 @@ "Client", "Context", "FastMCP", + "TaskConfig", "client", "settings", ] diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 7fd871d941..10bf4ee01f 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -15,6 +15,7 @@ from fastmcp.exceptions import PromptError from fastmcp.server.dependencies import get_context, without_injected_parameters +from fastmcp.server.tasks.config import TaskConfig from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger @@ -120,7 +121,7 @@ def from_function( tags: set[str] | None = None, enabled: bool | None = None, meta: dict[str, Any] | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> FunctionPrompt: """Create a Prompt from a function. @@ -158,12 +159,10 @@ class FunctionPrompt(Prompt): """A prompt that is a function.""" fn: Callable[..., PromptResult | Awaitable[PromptResult]] - task: Annotated[ - bool, - Field( - description="Whether this prompt supports background task execution (SEP-1686)" - ), - ] = False + task_config: Annotated[ + TaskConfig, + Field(description="Background task execution configuration (SEP-1686)."), + ] = Field(default_factory=lambda: TaskConfig(mode="forbidden")) @classmethod def from_function( @@ -176,7 +175,7 @@ def from_function( tags: set[str] | None = None, enabled: bool | None = None, meta: dict[str, Any] | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> FunctionPrompt: """Create a Prompt from a function. @@ -201,6 +200,15 @@ def from_function( description = description or inspect.getdoc(fn) + # Normalize task to TaskConfig and validate + if task is None: + task_config = TaskConfig(mode="forbidden") + elif isinstance(task, bool): + task_config = TaskConfig.from_bool(task) + else: + task_config = task + task_config.validate_function(fn, func_name) + # if the fn is a callable class, we need to get the __call__ method from here out if not inspect.isroutine(fn): fn = fn.__call__ @@ -208,13 +216,6 @@ def from_function( if isinstance(fn, staticmethod): fn = fn.__func__ # type: ignore[assignment] - # Validate that task=True requires async functions (after unwrapping) - if task and not inspect.iscoroutinefunction(fn): - raise ValueError( - f"Prompt '{func_name}' uses a sync function but has task=True. " - "Background tasks require async functions. Set task=False to disable." - ) - # Wrap fn to handle dependency resolution internally wrapped_fn = without_injected_parameters(fn) type_adapter = get_cached_typeadapter(wrapped_fn) @@ -271,7 +272,7 @@ def from_function( enabled=enabled if enabled is not None else True, fn=wrapped_fn, meta=meta, - task=task if task is not None else False, + task_config=task_config, ) def _convert_string_arguments(self, kwargs: dict[str, Any]) -> dict[str, Any]: diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 3611cbfcbd..6bb4dbd47c 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -20,6 +20,7 @@ from typing_extensions import Self from fastmcp.server.dependencies import get_context, without_injected_parameters +from fastmcp.server.tasks.config import TaskConfig from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.types import ( get_fn_name, @@ -77,7 +78,7 @@ def from_function( enabled: bool | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> FunctionResource: return FunctionResource.from_function( fn=fn, @@ -170,12 +171,10 @@ class FunctionResource(Resource): """ fn: Callable[..., Any] - task: Annotated[ - bool, - Field( - description="Whether this resource supports background task execution (SEP-1686)" - ), - ] = False + task_config: Annotated[ + TaskConfig, + Field(description="Background task execution configuration (SEP-1686)."), + ] = Field(default_factory=lambda: TaskConfig(mode="forbidden")) @classmethod def from_function( @@ -191,24 +190,22 @@ def from_function( enabled: bool | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> FunctionResource: """Create a FunctionResource from a function.""" if isinstance(uri, str): uri = AnyUrl(uri) - # Validate that task=True requires async functions - # Handle callable classes and staticmethods before checking - fn_to_check = fn - if not inspect.isroutine(fn) and callable(fn): - fn_to_check = fn.__call__ - if isinstance(fn_to_check, staticmethod): - fn_to_check = fn_to_check.__func__ - if task and not inspect.iscoroutinefunction(fn_to_check): - raise ValueError( - f"Resource '{name or get_fn_name(fn)}' uses a sync function but has task=True. " - "Background tasks require async functions. Set task=False to disable." - ) + func_name = name or get_fn_name(fn) + + # Normalize task to TaskConfig and validate + if task is None: + task_config = TaskConfig(mode="forbidden") + elif isinstance(task, bool): + task_config = TaskConfig.from_bool(task) + else: + task_config = task + task_config.validate_function(fn, func_name) # Wrap fn to handle dependency resolution internally wrapped_fn = without_injected_parameters(fn) @@ -225,7 +222,7 @@ def from_function( enabled=enabled if enabled is not None else True, annotations=annotations, meta=meta, - task=task if task is not None else False, + task_config=task_config, ) async def read(self) -> str | bytes: diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 45be54b82c..819bdea41d 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -18,6 +18,7 @@ from fastmcp.resources.resource import Resource from fastmcp.server.dependencies import get_context, without_injected_parameters +from fastmcp.server.tasks.config import TaskConfig from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.types import get_cached_typeadapter @@ -136,7 +137,7 @@ def from_function( enabled: bool | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> FunctionResourceTemplate: return FunctionResourceTemplate.from_function( fn=fn, @@ -231,12 +232,10 @@ class FunctionResourceTemplate(ResourceTemplate): """A template for dynamically creating resources.""" fn: Callable[..., Any] - task: Annotated[ - bool, - Field( - description="Whether this resource template supports background task execution (SEP-1686)" - ), - ] = False + task_config: Annotated[ + TaskConfig, + Field(description="Background task execution configuration (SEP-1686)."), + ] = Field(default_factory=lambda: TaskConfig(mode="forbidden")) async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: """Create a resource from the template with the given parameters.""" @@ -254,7 +253,7 @@ async def resource_read_fn() -> str | bytes: mime_type=self.mime_type, tags=self.tags, enabled=self.enabled, - task=self.task, + task=self.task_config, ) async def read(self, arguments: dict[str, Any]) -> str | bytes: @@ -302,7 +301,7 @@ def from_function( enabled: bool | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> FunctionResourceTemplate: """Create a template from a function.""" @@ -373,6 +372,15 @@ def from_function( description = description or inspect.getdoc(fn) + # Normalize task to TaskConfig and validate + if task is None: + task_config = TaskConfig(mode="forbidden") + elif isinstance(task, bool): + task_config = TaskConfig.from_bool(task) + else: + task_config = task + task_config.validate_function(fn, func_name) + # if the fn is a callable class, we need to get the __call__ method from here out if not inspect.isroutine(fn): fn = fn.__call__ @@ -380,13 +388,6 @@ def from_function( if isinstance(fn, staticmethod): fn = fn.__func__ - # Validate that task=True requires async functions (after unwrapping) - if task and not inspect.iscoroutinefunction(fn): - raise ValueError( - f"Resource template '{func_name}' uses a sync function but has task=True. " - "Background tasks require async functions. Set task=False to disable." - ) - wrapper_fn = without_injected_parameters(fn) type_adapter = get_cached_typeadapter(wrapper_fn) parameters = type_adapter.json_schema() @@ -408,5 +409,5 @@ def from_function( enabled=enabled if enabled is not None else True, annotations=annotations, meta=meta, - task=task if task is not None else False, + task_config=task_config, ) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 0dd8532eb3..4cb40c5a79 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -35,11 +35,14 @@ from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions from mcp.server.stdio import stdio_server +from mcp.shared.exceptions import McpError from mcp.types import ( + METHOD_NOT_FOUND, Annotations, AnyFunction, CallToolRequestParams, ContentBlock, + ErrorData, GetPromptResult, ToolAnnotations, ) @@ -71,6 +74,7 @@ ) from fastmcp.server.low_level import LowLevelServer from fastmcp.server.middleware import Middleware, MiddlewareContext +from fastmcp.server.tasks.config import TaskConfig from fastmcp.server.tasks.handlers import ( handle_prompt_as_task, handle_resource_as_task, @@ -401,21 +405,34 @@ async def _docket_lifespan(self) -> AsyncIterator[None]: # Register local task-enabled tools/prompts/resources with Docket # Only function-based variants support background tasks + # Register components where task execution is not "forbidden" for tool in self._tool_manager._tools.values(): - if isinstance(tool, FunctionTool) and tool.task: + if ( + isinstance(tool, FunctionTool) + and tool.task_config.mode != "forbidden" + ): docket.register(tool.fn) for prompt in self._prompt_manager._prompts.values(): - if isinstance(prompt, FunctionPrompt) and prompt.task: - # task=True requires async fn (validated at creation time) + if ( + isinstance(prompt, FunctionPrompt) + and prompt.task_config.mode != "forbidden" + ): + # task execution requires async fn (validated at creation time) docket.register(cast(Callable[..., Awaitable[Any]], prompt.fn)) for resource in self._resource_manager._resources.values(): - if isinstance(resource, FunctionResource) and resource.task: + if ( + isinstance(resource, FunctionResource) + and resource.task_config.mode != "forbidden" + ): docket.register(resource.fn) for template in self._resource_manager._templates.values(): - if isinstance(template, FunctionResourceTemplate) and template.task: + if ( + isinstance(template, FunctionResourceTemplate) + and template.task_config.mode != "forbidden" + ): docket.register(template.fn) # Set Docket in ContextVar so CurrentDocket can access it @@ -572,20 +589,37 @@ async def handler(req: mcp.types.ReadResourceRequest) -> mcp.types.ServerResult: pass # Check for task metadata and route appropriately - if task_meta and fastmcp.settings.enable_tasks: + if fastmcp.settings.enable_tasks: async with fastmcp.server.context.Context(fastmcp=self): try: resource = await self._resource_manager.get_resource(uri) - if ( - resource - and isinstance(resource, FunctionResource) - and resource.task - ): - # Convert TaskMetadata to dict for handler - task_meta_dict = task_meta.model_dump(exclude_none=True) - return await handle_resource_as_task( - self, str(uri), resource, task_meta_dict - ) + if resource and isinstance(resource, FunctionResource): + task_mode = resource.task_config.mode + + # Enforce mode="required" - must have task metadata + if task_mode == "required" and not task_meta: + raise McpError( + ErrorData( + code=METHOD_NOT_FOUND, + message=f"Resource '{uri}' requires task-augmented execution", + ) + ) + + # Enforce mode="forbidden" - must not have task metadata + if task_mode == "forbidden" and task_meta: + raise McpError( + ErrorData( + code=METHOD_NOT_FOUND, + message=f"Resource '{uri}' does not support task-augmented execution", + ) + ) + + # Route to background if task metadata present and mode allows + if task_meta and task_mode != "forbidden": + task_meta_dict = task_meta.model_dump(exclude_none=True) + return await handle_resource_as_task( + self, str(uri), resource, task_meta_dict + ) except NotFoundError: pass @@ -645,22 +679,38 @@ async def handler(req: mcp.types.GetPromptRequest) -> mcp.types.ServerResult: pass # Check for task metadata and route appropriately - if task_meta and fastmcp.settings.enable_tasks: + if fastmcp.settings.enable_tasks: async with fastmcp.server.context.Context(fastmcp=self): prompts = await self.get_prompts() prompt = prompts.get(name) - if prompt and isinstance(prompt, FunctionPrompt) and prompt.task: - # Convert TaskMetadata to dict for handler - task_meta_dict = task_meta.model_dump(exclude_none=True) - result = await handle_prompt_as_task( - self, name, arguments, task_meta_dict - ) - return mcp.types.ServerResult(result) - else: - logger.debug( - f"[{self.name}] Prompt {name} does not support background execution, " - "ignoring task metadata and executing synchronously" - ) + if prompt and isinstance(prompt, FunctionPrompt): + task_mode = prompt.task_config.mode + + # Enforce mode="required" - must have task metadata + if task_mode == "required" and not task_meta: + raise McpError( + ErrorData( + code=METHOD_NOT_FOUND, + message=f"Prompt '{name}' requires task-augmented execution", + ) + ) + + # Enforce mode="forbidden" - must not have task metadata + if task_mode == "forbidden" and task_meta: + raise McpError( + ErrorData( + code=METHOD_NOT_FOUND, + message=f"Prompt '{name}' does not support task-augmented execution", + ) + ) + + # Route to background if task metadata present and mode allows + if task_meta and task_mode != "forbidden": + task_meta_dict = task_meta.model_dump(exclude_none=True) + result = await handle_prompt_as_task( + self, name, arguments, task_meta_dict + ) + return mcp.types.ServerResult(result) # Synchronous execution result = await self._get_prompt_mcp(name, arguments) @@ -1324,23 +1374,35 @@ async def _call_tool_mcp( # No request context available - proceed without task metadata pass - if task_meta and fastmcp.settings.enable_tasks: - # Task metadata present - check if tool supports background execution + if fastmcp.settings.enable_tasks: tool = self._tool_manager._tools.get(key) - if tool and isinstance(tool, FunctionTool) and tool.task: - # Route to background execution - # Convert TaskMetadata to dict for handler - task_meta_dict = task_meta.model_dump(exclude_none=True) - return await handle_tool_as_task( - self, key, arguments, task_meta_dict - ) - else: - # Graceful degradation per SEP-1686 spec - logger.debug( - f"[{self.name}] Tool {key} does not support background execution, " - "ignoring task metadata and executing synchronously" - ) - # Fall through to synchronous execution + if tool and isinstance(tool, FunctionTool): + task_mode = tool.task_config.mode + + # Enforce mode="required" - must have task metadata + if task_mode == "required" and not task_meta: + raise McpError( + ErrorData( + code=METHOD_NOT_FOUND, + message=f"Tool '{key}' requires task-augmented execution", + ) + ) + + # Enforce mode="forbidden" - must not have task metadata + if task_mode == "forbidden" and task_meta: + raise McpError( + ErrorData( + code=METHOD_NOT_FOUND, + message=f"Tool '{key}' does not support task-augmented execution", + ) + ) + + # Route to background if task metadata present and mode allows + if task_meta and task_mode != "forbidden": + task_meta_dict = task_meta.model_dump(exclude_none=True) + return await handle_tool_as_task( + self, key, arguments, task_meta_dict + ) # Synchronous execution (normal path) result = await self._call_tool_middleware(key, arguments) @@ -1652,7 +1714,7 @@ def tool( exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> FunctionTool: ... @overload @@ -1670,7 +1732,7 @@ def tool( exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> Callable[[AnyFunction], FunctionTool]: ... def tool( @@ -1687,7 +1749,7 @@ def tool( exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> Callable[[AnyFunction], FunctionTool] | FunctionTool: """Decorator to register a tool. @@ -1761,8 +1823,8 @@ def my_tool(x: int) -> str: fn = name_or_fn tool_name = name # Use keyword name if provided, otherwise None - # Resolve task parameter to concrete boolean - supports_task: bool = ( + # Resolve task parameter + supports_task: bool | TaskConfig = ( task if task is not None else self._support_tasks_by_default ) @@ -1875,7 +1937,7 @@ def resource( enabled: bool | None = None, annotations: Annotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> Callable[[AnyFunction], Resource | ResourceTemplate]: """Decorator to register a function as a resource. @@ -1952,8 +2014,8 @@ def decorator(fn: AnyFunction) -> Resource | ResourceTemplate: ) ) - # Resolve task parameter to concrete boolean - supports_task: bool = ( + # Resolve task parameter + supports_task: bool | TaskConfig = ( task if task is not None else self._support_tasks_by_default ) @@ -2041,7 +2103,7 @@ def prompt( tags: set[str] | None = None, enabled: bool | None = None, meta: dict[str, Any] | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> FunctionPrompt: ... @overload @@ -2056,7 +2118,7 @@ def prompt( tags: set[str] | None = None, enabled: bool | None = None, meta: dict[str, Any] | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> Callable[[AnyFunction], FunctionPrompt]: ... def prompt( @@ -2070,7 +2132,7 @@ def prompt( tags: set[str] | None = None, enabled: bool | None = None, meta: dict[str, Any] | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt: """Decorator to register a prompt. @@ -2161,8 +2223,8 @@ def another_prompt(data: str) -> list[Message]: fn = name_or_fn prompt_name = name # Use keyword name if provided, otherwise None - # Resolve task parameter to concrete boolean - supports_task: bool = ( + # Resolve task parameter + supports_task: bool | TaskConfig = ( task if task is not None else self._support_tasks_by_default ) diff --git a/src/fastmcp/server/tasks/__init__.py b/src/fastmcp/server/tasks/__init__.py index 513644806e..43eef5d80f 100644 --- a/src/fastmcp/server/tasks/__init__.py +++ b/src/fastmcp/server/tasks/__init__.py @@ -3,6 +3,7 @@ This module implements protocol-level background task execution for MCP servers. """ +from fastmcp.server.tasks.config import TaskConfig, TaskMode from fastmcp.server.tasks.converters import ( convert_prompt_result, convert_resource_result, @@ -26,6 +27,8 @@ ) __all__ = [ + "TaskConfig", + "TaskMode", "build_task_key", "convert_prompt_result", "convert_resource_result", diff --git a/src/fastmcp/server/tasks/config.py b/src/fastmcp/server/tasks/config.py new file mode 100644 index 0000000000..ac2670da65 --- /dev/null +++ b/src/fastmcp/server/tasks/config.py @@ -0,0 +1,89 @@ +"""TaskConfig for MCP SEP-1686 background task execution modes. + +This module defines the configuration for how tools, resources, and prompts +handle task-augmented execution as specified in SEP-1686. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Literal + +# Task execution modes per SEP-1686 / MCP ToolExecution.taskSupport +TaskMode = Literal["forbidden", "optional", "required"] + + +@dataclass +class TaskConfig: + """Configuration for MCP background task execution (SEP-1686). + + Controls how a component handles task-augmented requests: + + - "forbidden": Component does not support task execution. Clients must not + request task augmentation; server returns -32601 if they do. + - "optional": Component supports both synchronous and task execution. + Client may request task augmentation or call normally. + - "required": Component requires task execution. Clients must request task + augmentation; server returns -32601 if they don't. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.tasks import TaskConfig + + mcp = FastMCP("MyServer") + + # Background execution required + @mcp.tool(task=TaskConfig(mode="required")) + async def long_running_task(): ... + + # Supports both modes (default when task=True) + @mcp.tool(task=TaskConfig(mode="optional")) + async def flexible_task(): ... + ``` + """ + + mode: TaskMode = "optional" + + @classmethod + def from_bool(cls, value: bool) -> TaskConfig: + """Convert boolean task flag to TaskConfig. + + Args: + value: True for "optional" mode, False for "forbidden" mode. + + Returns: + TaskConfig with appropriate mode. + """ + return cls(mode="optional" if value else "forbidden") + + def validate_function(self, fn: Callable[..., Any], name: str) -> None: + """Validate that function is compatible with this task config. + + Task execution requires async functions. Raises ValueError if mode + is "optional" or "required" but function is synchronous. + + Args: + fn: The function to validate (handles callable classes and staticmethods). + name: Name for error messages. + + Raises: + ValueError: If task execution is enabled but function is sync. + """ + if self.mode == "forbidden": + return + + # Unwrap callable classes and staticmethods + fn_to_check = fn + if not inspect.isroutine(fn) and callable(fn): + fn_to_check = fn.__call__ + if isinstance(fn_to_check, staticmethod): + fn_to_check = fn_to_check.__func__ + + if not inspect.iscoroutinefunction(fn_to_check): + raise ValueError( + f"'{name}' uses a sync function but has task execution enabled. " + "Background tasks require async functions." + ) diff --git a/src/fastmcp/server/tasks/converters.py b/src/fastmcp/server/tasks/converters.py index 3cd4ffb904..7029fae379 100644 --- a/src/fastmcp/server/tasks/converters.py +++ b/src/fastmcp/server/tasks/converters.py @@ -12,8 +12,6 @@ import mcp.types import pydantic_core -from fastmcp.tools.tool import ToolResult, _convert_to_content - if TYPE_CHECKING: from fastmcp.server.server import FastMCP @@ -35,6 +33,10 @@ async def convert_tool_result( Returns: CallToolResult with properly formatted content and structured content """ + # Import here to avoid circular import: + # tools/tool.py -> tasks/config.py -> tasks/__init__.py -> converters.py -> tools/tool.py + from fastmcp.tools.tool import ToolResult, _convert_to_content + # Get the tool to access its configuration tool = await server.get_tool(tool_name) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index d8aad60f11..2106b0f743 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -30,6 +30,7 @@ import fastmcp from fastmcp.server.dependencies import get_context, without_injected_parameters +from fastmcp.server.tasks.config import TaskConfig from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger @@ -201,7 +202,7 @@ def from_function( serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> FunctionTool: """Create a Tool from a function.""" return FunctionTool.from_function( @@ -269,12 +270,10 @@ def from_tool( class FunctionTool(Tool): fn: Callable[..., Any] - task: Annotated[ - bool, - Field( - description="Whether this tool supports background task execution (SEP-1686)" - ), - ] = False + task_config: Annotated[ + TaskConfig, + Field(description="Background task execution configuration (SEP-1686)."), + ] = Field(default_factory=lambda: TaskConfig(mode="forbidden")) def to_mcp_tool( self, @@ -291,11 +290,10 @@ def to_mcp_tool( include_fastmcp_meta=include_fastmcp_meta, **overrides ) - # Add task execution mode if this tool supports background tasks - # Per SEP-1686: tools declare task support via execution.task - # task values: "never" (no task support), "optional" (supports both), "always" (requires task) - if self.task and "execution" not in overrides: - mcp_tool.execution = ToolExecution(task="optional") + # Add task execution mode per SEP-1686 + # Only set execution if not overridden and mode is not "forbidden" + if self.task_config.mode != "forbidden" and "execution" not in overrides: + mcp_tool.execution = ToolExecution(taskSupport=self.task_config.mode) return mcp_tool @@ -314,7 +312,7 @@ def from_function( serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None, - task: bool | None = None, + task: bool | TaskConfig | None = None, ) -> FunctionTool: """Create a Tool from a function.""" if exclude_args and fastmcp.settings.deprecation_warnings: @@ -328,25 +326,21 @@ def from_function( stacklevel=2, ) - # Validate that task=True requires async functions - # Handle callable classes and staticmethods before checking - fn_to_check = fn - if not inspect.isroutine(fn) and callable(fn): - fn_to_check = fn.__call__ - if isinstance(fn_to_check, staticmethod): - fn_to_check = fn_to_check.__func__ - if task and not inspect.iscoroutinefunction(fn_to_check): - fn_name = name or getattr(fn, "__name__", repr(fn)) - raise ValueError( - f"Tool '{fn_name}' uses a sync function but has task=True. " - "Background tasks require async functions. Set task=False to disable." - ) - parsed_fn = ParsedFunction.from_function(fn, exclude_args=exclude_args) + func_name = name or parsed_fn.name - if name is None and parsed_fn.name == "": + if func_name == "": raise ValueError("You must provide a name for lambda functions") + # Normalize task to TaskConfig and validate + if task is None: + task_config = TaskConfig(mode="forbidden") + elif isinstance(task, bool): + task_config = TaskConfig.from_bool(task) + else: + task_config = task + task_config.validate_function(fn, func_name) + if isinstance(output_schema, NotSetT): final_output_schema = parsed_fn.output_schema else: @@ -375,7 +369,7 @@ def from_function( serializer=serializer, meta=meta, enabled=enabled if enabled is not None else True, - task=task if task is not None else False, + task_config=task_config, ) async def run(self, arguments: dict[str, Any]) -> ToolResult: diff --git a/tests/client/tasks/test_task_result_caching.py b/tests/client/tasks/test_task_result_caching.py index 8e75f0cae4..e4c5c83d86 100644 --- a/tests/client/tasks/test_task_result_caching.py +++ b/tests/client/tasks/test_task_result_caching.py @@ -132,86 +132,95 @@ async def sample_tool() -> str: 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: - nonlocal call_count - call_count += 1 - return call_count + return 1 async with Client(mcp) as client: - # Request as task, but server will execute immediately + # Request as task, but mode="forbidden" will reject with error task = await client.call_tool("non_task_tool", task=True) - # Should be immediate (graceful degradation) + # 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 value - assert result1.data == 1 - assert result2.data == 1 - assert result3.data == 1 + # 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_immediate_prompt_task_caches_result(): - """Immediate prompt tasks cache results.""" - call_count = 0 +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: - nonlocal call_count - call_count += 1 - return f"Immediate: {call_count}" + return "Immediate" async with Client(mcp) as client: - task = await client.get_prompt("non_task_prompt", task=True) - - # Should be immediate - assert task.returned_immediately + # Prompts with mode="forbidden" raise McpError when called with task=True + with pytest.raises(McpError): + await client.get_prompt("non_task_prompt", task=True) - 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_forbidden_mode_resource_raises_error(): + """Resources with task=False (mode=forbidden) raise error.""" + import pytest + from mcp.shared.exceptions import McpError - -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: + 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 f"Immediate: {call_count}" + return 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 + # 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() - # Verify caching - assert result1 is result2 - assert result1[0].text == "Immediate: 1" + # All should return cached value + assert result1.data == 1 + assert result2.data == 1 + assert result3.data == 1 + + # Verify they're the same object (cached) + assert result1 is result2 is result3 async def test_cache_persists_across_mixed_access_patterns(): diff --git a/tests/server/tasks/test_server_tasks_parameter.py b/tests/server/tasks/test_server_tasks_parameter.py index 2a87434f63..842cd02a25 100644 --- a/tests/server/tasks/test_server_tasks_parameter.py +++ b/tests/server/tasks/test_server_tasks_parameter.py @@ -49,7 +49,10 @@ async def my_resource() -> str: 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 my_resource() -> str: 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 my_tool2() -> str: 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 default_tool() -> str: 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 default_tool() -> str: 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 explicit_false_resource() -> str: 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 explicit_false_resource() -> str: ) 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 tool_inherits_false() -> str: 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 templated_resource(item_id: str) -> str: 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 shared_name_prompt() -> str: 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) diff --git a/tests/server/tasks/test_sync_function_task_disabled.py b/tests/server/tasks/test_sync_function_task_disabled.py index 9c45665722..854605e61b 100644 --- a/tests/server/tasks/test_sync_function_task_disabled.py +++ b/tests/server/tasks/test_sync_function_task_disabled.py @@ -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 async_tool(x: int) -> int: """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 async_prompt() -> str: """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 async_resource() -> str: """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 @@ def sync_tool(x: int) -> int: 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 @@ def sync_prompt() -> str: 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 @@ def sync_resource() -> str: 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 __call__(self, x: int) -> int: # 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 __call__(self) -> str: # 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 @@ class SyncCallableTool: 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) diff --git a/tests/server/tasks/test_task_config_modes.py b/tests/server/tasks/test_task_config_modes.py new file mode 100644 index 0000000000..6870301b51 --- /dev/null +++ b/tests/server/tasks/test_task_config_modes.py @@ -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] diff --git a/tests/server/tasks/test_task_prompts.py b/tests/server/tasks/test_task_prompts.py index 6ca88f4496..105de14bdf 100644 --- a/tests/server/tasks/test_task_prompts.py +++ b/tests/server/tasks/test_task_prompts.py @@ -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 + ) diff --git a/tests/server/tasks/test_task_resources.py b/tests/server/tasks/test_task_resources.py index 308e97a6f1..df02caece7 100644 --- a/tests/server/tasks/test_task_resources.py +++ b/tests/server/tasks/test_task_resources.py @@ -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) - - # 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 + # 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) + + # 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 + ) diff --git a/tests/server/tasks/test_task_tools.py b/tests/server/tasks/test_task_tools.py index 6d5056960c..ef4d96a59b 100644 --- a/tests/server/tasks/test_task_tools.py +++ b/tests/server/tasks/test_task_tools.py @@ -88,16 +88,15 @@ async def coordinated_tool() -> str: 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) diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py index 8603813282..3052da4542 100644 --- a/tests/server/test_tool_annotations.py +++ b/tests/server/test_tool_annotations.py @@ -222,7 +222,7 @@ def create_item(name: str, value: int) -> dict[str, Any]: 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 background_tool(data: str) -> str: 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(): diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index c707bf7999..de5836fafd 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -52,7 +52,7 @@ def add(a: int, b: int) -> int: "type": "object", "x-fastmcp-wrap-result": True, }, - "task": False, + "task": {"mode": "forbidden"}, "fn": HasName("add"), } ) @@ -100,7 +100,7 @@ async def fetch_data(url: str) -> str: "type": "object", "x-fastmcp-wrap-result": True, }, - "task": False, + "task": {"mode": "forbidden"}, "fn": HasName("fetch_data"), } ) @@ -135,7 +135,7 @@ def __call__(self, x: int, y: int) -> int: "type": "object", "x-fastmcp-wrap-result": True, }, - "task": False, + "task": {"mode": "forbidden"}, } ) @@ -169,7 +169,7 @@ async def __call__(self, x: int, y: int) -> int: "type": "object", "x-fastmcp-wrap-result": True, }, - "task": False, + "task": {"mode": "forbidden"}, } ) @@ -211,7 +211,7 @@ def create_user(user: UserInput, flag: bool) -> dict: "type": "object", }, "output_schema": {"additionalProperties": True, "type": "object"}, - "task": False, + "task": {"mode": "forbidden"}, "fn": HasName("create_user"), } ) @@ -274,7 +274,7 @@ def test_lambda(self): "required": ["x"], "type": "object", }, - "task": False, + "task": {"mode": "forbidden"}, } ) @@ -307,7 +307,7 @@ def add(_a: int, _b: int) -> int: "required": ["_a", "_b"], "type": "object", }, - "task": False, + "task": {"mode": "forbidden"}, } ) @@ -362,7 +362,7 @@ def add(self, x: int, y: int) -> int: "type": "object", "x-fastmcp-wrap-result": True, }, - "task": False, + "task": {"mode": "forbidden"}, } ) From b291abb460505023cfd4297baec50b150622efdd Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 6 Dec 2025 21:05:11 -0500 Subject: [PATCH 2/2] Fix inline snapshots for task_config rename --- tests/tools/test_tool.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index de5836fafd..180f1bdc2e 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -52,8 +52,8 @@ def add(a: int, b: int) -> int: "type": "object", "x-fastmcp-wrap-result": True, }, - "task": {"mode": "forbidden"}, "fn": HasName("add"), + "task_config": {"mode": "forbidden"}, } ) @@ -100,8 +100,8 @@ async def fetch_data(url: str) -> str: "type": "object", "x-fastmcp-wrap-result": True, }, - "task": {"mode": "forbidden"}, "fn": HasName("fetch_data"), + "task_config": {"mode": "forbidden"}, } ) @@ -135,7 +135,7 @@ def __call__(self, x: int, y: int) -> int: "type": "object", "x-fastmcp-wrap-result": True, }, - "task": {"mode": "forbidden"}, + "task_config": {"mode": "forbidden"}, } ) @@ -169,7 +169,7 @@ async def __call__(self, x: int, y: int) -> int: "type": "object", "x-fastmcp-wrap-result": True, }, - "task": {"mode": "forbidden"}, + "task_config": {"mode": "forbidden"}, } ) @@ -211,8 +211,8 @@ def create_user(user: UserInput, flag: bool) -> dict: "type": "object", }, "output_schema": {"additionalProperties": True, "type": "object"}, - "task": {"mode": "forbidden"}, "fn": HasName("create_user"), + "task_config": {"mode": "forbidden"}, } ) @@ -274,7 +274,7 @@ def test_lambda(self): "required": ["x"], "type": "object", }, - "task": {"mode": "forbidden"}, + "task_config": {"mode": "forbidden"}, } ) @@ -307,7 +307,7 @@ def add(_a: int, _b: int) -> int: "required": ["_a", "_b"], "type": "object", }, - "task": {"mode": "forbidden"}, + "task_config": {"mode": "forbidden"}, } ) @@ -362,7 +362,7 @@ def add(self, x: int, y: int) -> int: "type": "object", "x-fastmcp-wrap-result": True, }, - "task": {"mode": "forbidden"}, + "task_config": {"mode": "forbidden"}, } )