mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 05:24:18 +02:00
[SEP-1686] Raise ValueError when sync functions have task=True (#2554)
* Raise ValueError when sync functions have task=True Move validation from server.py decorators to the from_function() class methods on FunctionTool, FunctionPrompt, FunctionResource, and FunctionResourceTemplate. This ensures the check runs regardless of how the objects are created. * Fix async check for callable classes and staticmethods Move the task=True async validation to run AFTER callable classes and staticmethods are unwrapped, preventing false positives for async callable classes with sync-looking signatures.
This commit is contained in:
parent
66aaf420c9
commit
e47abb4bd7
8 changed files with 155 additions and 206 deletions
|
|
@ -208,6 +208,13 @@ class FunctionPrompt(Prompt):
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -197,6 +197,19 @@ class FunctionResource(Resource):
|
|||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -369,6 +369,13 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import re
|
||||
import secrets
|
||||
|
|
@ -1787,16 +1786,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
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 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
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 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -314,6 +314,20 @@ class FunctionTool(Tool):
|
|||
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 == "<lambda>":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
async def test_sync_tool_with_explicit_task_true_raises():
|
||||
"""Sync tool with task=True raises ValueError."""
|
||||
mcp = FastMCP("test")
|
||||
|
||||
with caplog_for_fastmcp(caplog):
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
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
|
||||
async def test_sync_tool_with_inherited_task_true_raises():
|
||||
"""Sync tool inheriting task=True from server raises ValueError."""
|
||||
mcp = FastMCP("test", tasks=True)
|
||||
|
||||
# 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_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
|
||||
async def test_sync_prompt_with_explicit_task_true_raises():
|
||||
"""Sync prompt with task=True raises ValueError."""
|
||||
mcp = FastMCP("test")
|
||||
|
||||
# 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)
|
||||
|
||||
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
|
||||
async def test_sync_prompt_with_inherited_task_true_raises():
|
||||
"""Sync prompt inheriting task=True from server raises ValueError."""
|
||||
mcp = FastMCP("test", tasks=True)
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
async def test_sync_resource_with_explicit_task_true_raises():
|
||||
"""Sync resource with task=True raises ValueError."""
|
||||
mcp = FastMCP("test")
|
||||
|
||||
# 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)
|
||||
|
||||
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
|
||||
async def test_sync_resource_with_inherited_task_true_raises():
|
||||
"""Sync resource inheriting task=True from server raises ValueError."""
|
||||
mcp = FastMCP("test", tasks=True)
|
||||
|
||||
# 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_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 test_async_resource_with_task_true_remains_enabled():
|
|||
# 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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue