diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 6da6ad6d91..ddf92cd92a 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -208,6 +208,13 @@ 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"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) diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index d3a40bbc84..279132807b 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -197,6 +197,19 @@ def from_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." + ) + # Wrap fn to handle dependency resolution internally wrapped_fn = without_injected_parameters(fn) diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 2a87e8949f..48350705be 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -369,6 +369,13 @@ 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() diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 0d07204041..64dc3884e7 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import inspect import re import secrets @@ -1787,16 +1786,6 @@ def my_tool(x: int) -> str: task if task is not None else self._support_tasks_by_default ) - # Disable task support for sync functions (Docket requires async) - if supports_task and not asyncio.iscoroutinefunction(fn): - if task is True: - # User explicitly requested task=True for sync function - logger.warning( - f"Tool '{tool_name or fn.__name__}' has task=True but is synchronous. " - "Background task support requires async functions. Disabling task support." - ) - supports_task = False - # Register the tool immediately and return the tool object # Note: Deprecation warning for exclude_args is handled in Tool.from_function tool = Tool.from_function( @@ -1988,16 +1977,6 @@ def decorator(fn: AnyFunction) -> Resource | ResourceTemplate: task if task is not None else self._support_tasks_by_default ) - # Disable task support for sync functions (Docket requires async) - if supports_task and not asyncio.iscoroutinefunction(fn): - if task is True: - # User explicitly requested task=True for sync function - logger.warning( - f"Resource '{uri}' has task=True but is synchronous. " - "Background task support requires async functions. Disabling task support." - ) - supports_task = False - # Check if this should be a template has_uri_params = "{" in uri and "}" in uri # Use wrapper to check for user-facing parameters @@ -2207,16 +2186,6 @@ def another_prompt(data: str) -> list[Message]: task if task is not None else self._support_tasks_by_default ) - # Disable task support for sync functions (Docket requires async) - if supports_task and not asyncio.iscoroutinefunction(fn): - if task is True: - # User explicitly requested task=True for sync function - logger.warning( - f"Prompt '{prompt_name or fn.__name__}' has task=True but is synchronous. " - "Background task support requires async functions. Disabling task support." - ) - supports_task = False - # Register the prompt immediately prompt = Prompt.from_function( fn=fn, diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 31f9636d62..b89a13b201 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -314,6 +314,20 @@ 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) if name is None and parsed_fn.name == "": diff --git a/tests/server/tasks/test_sync_function_task_disabled.py b/tests/server/tasks/test_sync_function_task_disabled.py index 318c0fc164..d4b6baf091 100644 --- a/tests/server/tasks/test_sync_function_task_disabled.py +++ b/tests/server/tasks/test_sync_function_task_disabled.py @@ -1,229 +1,86 @@ """ Tests that synchronous functions cannot be used as background tasks. -Docket requires async functions for background execution. FastMCP automatically -disables task support for sync functions, with warnings for explicit task=True. +Docket requires async functions for background execution. FastMCP raises +ValueError when task=True is used with a sync function. """ -from pytest import LogCaptureFixture +import pytest from fastmcp import FastMCP -from fastmcp.client import Client -from fastmcp.utilities.tests import caplog_for_fastmcp -async def test_sync_tool_with_explicit_task_true_warns_and_disables( - caplog: LogCaptureFixture, -): - """Sync tool with task=True logs warning and disables task support.""" - import logging - - with caplog_for_fastmcp(caplog): - caplog.set_level(logging.INFO) +async def test_sync_tool_with_explicit_task_true_raises(): + """Sync tool with task=True raises ValueError.""" + mcp = FastMCP("test") - mcp = FastMCP("test") + with pytest.raises(ValueError, match="uses a sync function but has task=True"): @mcp.tool(task=True) def sync_tool(x: int) -> int: """A synchronous tool.""" - logging.getLogger("fastmcp.myserver").info("I came from the tool!") return x * 2 - # Should have logged a warning during decoration - assert "task=True but is synchronous" in caplog.text - assert "Disabling task support" in caplog.text - - # Tool should have task=False after being disabled - tool = await mcp.get_tool("sync_tool") - assert tool.task is False - - # Verify execution: even if client requests task=True, should execute immediately - async with Client(mcp) as client: - task = await client.call_tool("sync_tool", {"x": 5}, task=True) - assert task.returned_immediately - result = await task.result() - assert result.data == 10 - - # Should have seen the log from inside the function - assert "I came from the tool!" in caplog.text +async def test_sync_tool_with_inherited_task_true_raises(): + """Sync tool inheriting task=True from server raises ValueError.""" + mcp = FastMCP("test", tasks=True) -async def test_sync_tool_with_inherited_task_true_quietly_disables( - caplog: LogCaptureFixture, -): - """Sync tool inheriting task=True from server disables quietly (no warning).""" - import logging - - with caplog_for_fastmcp(caplog): - caplog.set_level(logging.INFO) - - mcp = FastMCP("test", tasks=True) + with pytest.raises(ValueError, match="uses a sync function but has task=True"): @mcp.tool() # Inherits task=True from server def sync_tool(x: int) -> int: """A synchronous tool.""" - logging.getLogger("fastmcp.myserver").info("I came from the tool!") return x * 2 - # Should NOT have logged a warning (quietly disabled) - assert "task=True but is synchronous" not in caplog.text - - # Tool should have task=False after being disabled - tool = await mcp.get_tool("sync_tool") - assert tool.task is False - - # Verify execution: should execute immediately - async with Client(mcp) as client: - task = await client.call_tool("sync_tool", {"x": 3}, task=True) - assert task.returned_immediately - result = await task.result() - assert result.data == 6 - # Should have seen the log from inside the function - assert "I came from the tool!" in caplog.text - - -async def test_sync_prompt_with_explicit_task_true_warns_and_disables( - caplog: LogCaptureFixture, -): - """Sync prompt with task=True logs warning and disables task support.""" - import logging - - with caplog_for_fastmcp(caplog): - caplog.set_level(logging.INFO) +async def test_sync_prompt_with_explicit_task_true_raises(): + """Sync prompt with task=True raises ValueError.""" + mcp = FastMCP("test") - mcp = FastMCP("test") + with pytest.raises(ValueError, match="uses a sync function but has task=True"): @mcp.prompt(task=True) def sync_prompt() -> str: """A synchronous prompt.""" - logging.getLogger("fastmcp.myserver").info("I came from the prompt!") return "Hello" - # Should have logged a warning during decoration - assert "task=True but is synchronous" in caplog.text - assert "Disabling task support" in caplog.text - - # Prompt should have task=False - prompt = await mcp.get_prompt("sync_prompt") - assert prompt.task is False - - # Verify execution: should execute immediately - async with Client(mcp) as client: - task = await client.get_prompt("sync_prompt", task=True) - assert task.returned_immediately - result = await task.result() - assert "Hello" in str(result) - - # Should have seen the log from inside the function - assert "I came from the prompt!" in caplog.text - -async def test_sync_prompt_with_inherited_task_true_quietly_disables( - caplog: LogCaptureFixture, -): - """Sync prompt inheriting task=True disables quietly.""" - import logging +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 caplog_for_fastmcp(caplog): - caplog.set_level(logging.INFO) - - mcp = FastMCP("test", tasks=True) + with pytest.raises(ValueError, match="uses a sync function but has task=True"): @mcp.prompt() # Inherits task=True from server def sync_prompt() -> str: """A synchronous prompt.""" - logging.getLogger("fastmcp.myserver").info("I came from the prompt!") return "Hello" - # Should NOT have logged a warning (quietly disabled) - assert "task=True but is synchronous" not in caplog.text - - # Prompt should have task=False - prompt = await mcp.get_prompt("sync_prompt") - assert prompt.task is False - - # Verify execution: should execute immediately - async with Client(mcp) as client: - task = await client.get_prompt("sync_prompt", task=True) - assert task.returned_immediately - result = await task.result() - assert "Hello" in str(result) - - # Should have seen the log from inside the function - assert "I came from the prompt!" in caplog.text - -async def test_sync_resource_with_explicit_task_true_warns_and_disables( - caplog: LogCaptureFixture, -): - """Sync resource with task=True logs warning and disables task support.""" - import logging - - with caplog_for_fastmcp(caplog): - caplog.set_level(logging.INFO) +async def test_sync_resource_with_explicit_task_true_raises(): + """Sync resource with task=True raises ValueError.""" + mcp = FastMCP("test") - mcp = FastMCP("test") + with pytest.raises(ValueError, match="uses a sync function but has task=True"): @mcp.resource("test://sync", task=True) def sync_resource() -> str: """A synchronous resource.""" - logging.getLogger("fastmcp.myserver").info("I came from the resource!") return "data" - # Should have logged a warning during decoration - assert "task=True but is synchronous" in caplog.text - assert "Disabling task support" in caplog.text - - # Resource should have task=False - resource = await mcp._resource_manager.get_resource("test://sync") - assert resource.task is False - - # Verify execution: should execute immediately - async with Client(mcp) as client: - task = await client.read_resource("test://sync", task=True) - assert task.returned_immediately - result = await task.result() - assert "data" in str(result) - # Should have seen the log from inside the function - assert "I came from the resource!" in caplog.text +async def test_sync_resource_with_inherited_task_true_raises(): + """Sync resource inheriting task=True from server raises ValueError.""" + mcp = FastMCP("test", tasks=True) - -async def test_sync_resource_with_inherited_task_true_quietly_disables( - caplog: LogCaptureFixture, -): - """Sync resource inheriting task=True disables quietly.""" - import logging - - with caplog_for_fastmcp(caplog): - caplog.set_level(logging.INFO) - - mcp = FastMCP("test", tasks=True) + with pytest.raises(ValueError, match="uses a sync function but has task=True"): @mcp.resource("test://sync") # Inherits task=True from server def sync_resource() -> str: """A synchronous resource.""" - logging.getLogger("fastmcp.myserver").info("I came from the resource!") return "data" - # Should NOT have logged a warning (quietly disabled) - assert "task=True but is synchronous" not in caplog.text - - # Resource should have task=False - resource = await mcp._resource_manager.get_resource("test://sync") - assert resource.task is False - - # Verify execution: should execute immediately - async with Client(mcp) as client: - task = await client.read_resource("test://sync", task=True) - assert task.returned_immediately - result = await task.result() - assert "data" in str(result) - - # Should have seen the log from inside the function - assert "I came from the resource!" in caplog.text - async def test_async_tool_with_task_true_remains_enabled(): """Async tools with task=True keep task support enabled.""" @@ -265,3 +122,85 @@ async def async_resource() -> str: # Resource should have task=True resource = await mcp._resource_manager.get_resource("test://async") assert resource.task is True + + +async def test_sync_tool_with_task_false_works(): + """Sync tool with explicit task=False works (no error).""" + mcp = FastMCP("test", tasks=True) + + @mcp.tool(task=False) # Explicitly disable + def sync_tool(x: int) -> int: + """A synchronous tool.""" + return x * 2 + + tool = await mcp.get_tool("sync_tool") + assert tool.task is False + + +async def test_sync_prompt_with_task_false_works(): + """Sync prompt with explicit task=False works (no error).""" + mcp = FastMCP("test", tasks=True) + + @mcp.prompt(task=False) # Explicitly disable + def sync_prompt() -> str: + """A synchronous prompt.""" + return "Hello" + + prompt = await mcp.get_prompt("sync_prompt") + assert prompt.task is False + + +async def test_sync_resource_with_task_false_works(): + """Sync resource with explicit task=False works (no error).""" + mcp = FastMCP("test", tasks=True) + + @mcp.resource("test://sync", task=False) # Explicitly disable + def sync_resource() -> str: + """A synchronous resource.""" + return "data" + + resource = await mcp._resource_manager.get_resource("test://sync") + assert resource.task is False + + +# ============================================================================= +# Callable classes and staticmethods with async __call__ +# ============================================================================= + + +async def test_async_callable_class_tool_with_task_true_works(): + """Callable class with async __call__ and task=True should work.""" + from fastmcp.tools import Tool + + class AsyncCallableTool: + async def __call__(self, x: int) -> int: + return x * 2 + + # Callable classes use Tool.from_function() directly + tool = Tool.from_function(AsyncCallableTool(), task=True) + assert tool.task is True + + +async def test_async_callable_class_prompt_with_task_true_works(): + """Callable class with async __call__ and task=True should work.""" + from fastmcp.prompts import Prompt + + class AsyncCallablePrompt: + async def __call__(self) -> str: + return "Hello" + + # Callable classes use Prompt.from_function() directly + prompt = Prompt.from_function(AsyncCallablePrompt(), task=True) + assert prompt.task is True + + +async def test_sync_callable_class_tool_with_task_true_raises(): + """Callable class with sync __call__ and task=True should raise.""" + from fastmcp.tools import Tool + + class SyncCallableTool: + def __call__(self, x: int) -> int: + return x * 2 + + with pytest.raises(ValueError, match="uses a sync function but has task=True"): + Tool.from_function(SyncCallableTool(), task=True) diff --git a/tests/server/tasks/test_task_capabilities.py b/tests/server/tasks/test_task_capabilities.py index 4a50504606..8e7177402b 100644 --- a/tests/server/tasks/test_task_capabilities.py +++ b/tests/server/tasks/test_task_capabilities.py @@ -20,7 +20,7 @@ async def test_capabilities_include_tasks_when_enabled(): mcp = FastMCP("capability-test") @mcp.tool() - def test_tool() -> str: + async def test_tool() -> str: return "test" async with Client(mcp) as client: @@ -89,7 +89,7 @@ async def test_enable_tasks_requires_enable_docket(): mcp = FastMCP("config-test") @mcp.tool() - def test_tool() -> str: + async def test_tool() -> str: return "test" # Should fail when trying to start server (during lifespan) @@ -107,7 +107,7 @@ async def test_client_advertises_task_capability_when_enabled(): mcp = FastMCP("client-cap-test") @mcp.tool() - def test_tool() -> str: + async def test_tool() -> str: return "test" async with Client(mcp) as client: diff --git a/tests/server/tasks/test_task_return_types.py b/tests/server/tasks/test_task_return_types.py index 4b6ed11b71..f3519e0c7a 100644 --- a/tests/server/tasks/test_task_return_types.py +++ b/tests/server/tasks/test_task_return_types.py @@ -186,12 +186,12 @@ async def resource_return_server(): mcp = FastMCP("resource-return-test") @mcp.resource("text://simple", task=True) - def simple_text() -> str: + async def simple_text() -> str: """Return simple text content.""" return "Simple text resource" @mcp.resource("data://json", task=True) - def json_data() -> dict[str, Any]: + async def json_data() -> dict[str, Any]: """Return JSON-like data.""" return {"key": "value", "count": 123}