fastmcp/tests/server/tasks/test_task_meta_parameter.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

314 lines
11 KiB
Python

"""
Tests for the explicit task_meta parameter on FastMCP.call_tool().
These tests verify that the task_meta parameter provides explicit control
over sync vs task execution, replacing implicit contextvar-based behavior.
"""
import mcp.types
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.exceptions import ToolError
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.server.tasks.config import TaskMeta
from fastmcp.tools.base import Tool, ToolResult
class TestTaskMetaParameter:
"""Tests for task_meta parameter on FastMCP.call_tool()."""
async def test_task_meta_none_returns_tool_result(self):
"""With task_meta=None (default), call_tool returns ToolResult."""
server = FastMCP("test")
@server.tool
async def simple_tool(x: int) -> int:
return x * 2
result = await server.call_tool("simple_tool", {"x": 5})
first_content = result.content[0]
assert isinstance(first_content, mcp.types.TextContent)
assert first_content.text == "10"
async def test_task_meta_none_on_task_enabled_tool_still_returns_tool_result(self):
"""Even for task=True tools, task_meta=None returns ToolResult synchronously."""
server = FastMCP("test")
@server.tool(task=True)
async def task_enabled_tool(x: int) -> int:
return x * 2
# Without task_meta, should execute synchronously
result = await server.call_tool("task_enabled_tool", {"x": 5})
first_content = result.content[0]
assert isinstance(first_content, mcp.types.TextContent)
assert first_content.text == "10"
async def test_task_meta_on_forbidden_tool_raises_error(self):
"""Providing task_meta to a task=False tool raises ToolError."""
server = FastMCP("test")
@server.tool(task=False)
async def sync_only_tool(x: int) -> int:
return x * 2
# Error is raised before docket is needed (McpError wrapped as ToolError)
with pytest.raises(ToolError) as exc_info:
await server.call_tool("sync_only_tool", {"x": 5}, task_meta=TaskMeta())
assert "does not support task-augmented execution" in str(exc_info.value)
async def test_task_meta_fn_key_auto_populated_in_call_tool(self):
"""fn_key is auto-populated from tool name in call_tool()."""
server = FastMCP("test")
@server.tool(task=True)
async def auto_key_tool() -> str:
return "done"
# Verify fn_key starts as None
task_meta = TaskMeta()
assert task_meta.fn_key is None
# call_tool enriches the task_meta before passing to _run
# We test this via the client integration path
async with Client(server) as client:
result = await client.call_tool("auto_key_tool", {}, task=True)
# Should succeed because fn_key was auto-populated
from fastmcp.client.tasks import ToolTask
assert isinstance(result, ToolTask)
async def test_task_meta_fn_key_enrichment_logic(self):
"""Verify that fn_key enrichment uses Tool.make_key()."""
# Direct test of the enrichment logic
tool_name = "my_tool"
expected_key = Tool.make_key(tool_name)
assert expected_key == "tool:my_tool"
class TestTaskMetaTTL:
"""Tests for task_meta.ttl behavior."""
async def test_task_with_custom_ttl_creates_task(self):
"""task_meta.ttl is passed through when creating tasks."""
server = FastMCP("test")
@server.tool(task=True)
async def ttl_tool() -> str:
return "done"
custom_ttl_ms = 30000 # 30 seconds
async with Client(server) as client:
# Use client.call_tool with task=True and ttl
task = await client.call_tool("ttl_tool", {}, task=True, ttl=custom_ttl_ms)
from fastmcp.client.tasks import ToolTask
assert isinstance(task, ToolTask)
# Verify task completes successfully
result = await task.result()
assert "done" in str(result)
async def test_task_without_ttl_uses_default(self):
"""task_meta.ttl=None uses docket.execution_ttl default."""
server = FastMCP("test")
@server.tool(task=True)
async def default_ttl_tool() -> str:
return "done"
async with Client(server) as client:
# Use client.call_tool with task=True, default ttl
task = await client.call_tool("default_ttl_tool", {}, task=True)
from fastmcp.client.tasks import ToolTask
assert isinstance(task, ToolTask)
# Verify task completes successfully
result = await task.result()
assert "done" in str(result)
class TrackingMiddleware(Middleware):
"""Middleware that tracks tool calls."""
def __init__(self, calls: list[str]):
super().__init__()
self._calls = calls
async def on_call_tool(
self,
context: MiddlewareContext[mcp.types.CallToolRequestParams],
call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult],
) -> ToolResult:
if context.method:
self._calls.append(context.method)
return await call_next(context)
class TestTaskMetaMiddleware:
"""Tests that task_meta is properly propagated through middleware."""
async def test_task_meta_propagated_through_middleware(self):
"""task_meta is passed through middleware chain."""
server = FastMCP("test")
middleware_saw_request: list[str] = []
@server.tool(task=True)
async def middleware_test_tool() -> str:
return "done"
server.add_middleware(TrackingMiddleware(middleware_saw_request))
async with Client(server) as client:
# Use client to trigger the middleware chain
task = await client.call_tool("middleware_test_tool", {}, task=True)
# Middleware should have run
assert "tools/call" in middleware_saw_request
# And task should have been created
from fastmcp.client.tasks import ToolTask
assert isinstance(task, ToolTask)
class TestTaskMetaClientIntegration:
"""Tests that task_meta works correctly with the Client."""
async def test_client_task_true_maps_to_task_meta(self):
"""Client's task=True creates proper task_meta on server."""
server = FastMCP("test")
@server.tool(task=True)
async def client_test_tool(x: int) -> int:
return x * 2
async with Client(server) as client:
# Client passes task=True, server receives as task_meta
task = await client.call_tool("client_test_tool", {"x": 5}, task=True)
# Should get back a ToolTask (client wrapper)
from fastmcp.client.tasks import ToolTask
assert isinstance(task, ToolTask)
# Wait for result
result = await task.result()
assert "10" in str(result)
async def test_client_without_task_gets_immediate_result(self):
"""Client without task=True gets immediate result."""
server = FastMCP("test")
@server.tool(task=True)
async def immediate_tool(x: int) -> int:
return x * 2
async with Client(server) as client:
# No task=True, should execute synchronously
result = await client.call_tool("immediate_tool", {"x": 5})
# Should get CallToolResult directly
assert "10" in str(result)
async def test_client_task_with_custom_ttl(self):
"""Client can pass custom TTL for task execution."""
server = FastMCP("test")
@server.tool(task=True)
async def custom_ttl_tool() -> str:
return "done"
custom_ttl_ms = 60000 # 60 seconds
async with Client(server) as client:
task = await client.call_tool(
"custom_ttl_tool", {}, task=True, ttl=custom_ttl_ms
)
from fastmcp.client.tasks import ToolTask
assert isinstance(task, ToolTask)
# Verify task completes successfully
result = await task.result()
assert "done" in str(result)
class TestTaskMetaDirectServerCall:
"""Tests for direct server calls (tool calling another tool)."""
async def test_tool_can_call_another_tool_with_task(self):
"""A tool can call another tool as a background task."""
server = FastMCP("test")
@server.tool(task=True)
async def inner_tool(x: int) -> int:
return x * 2
@server.tool
async def outer_tool(x: int) -> str:
# Call inner tool as background task
result = await server.call_tool(
"inner_tool", {"x": x}, task_meta=TaskMeta()
)
# Should get CreateTaskResult since we're in server context
return f"Created task: {result.task.taskId}"
async with Client(server) as client:
# Call outer_tool which internally calls inner_tool with task_meta
result = await client.call_tool("outer_tool", {"x": 5})
# The outer tool should have successfully created a background task
assert "Created task:" in str(result)
async def test_tool_can_call_another_tool_synchronously(self):
"""A tool can call another tool synchronously (no task_meta)."""
server = FastMCP("test")
@server.tool(task=True)
async def inner_tool(x: int) -> int:
return x * 2
@server.tool
async def outer_tool(x: int) -> str:
# Call inner tool synchronously (no task_meta)
result = await server.call_tool("inner_tool", {"x": x})
# Should get ToolResult directly
first_content = result.content[0]
assert isinstance(first_content, mcp.types.TextContent)
return f"Got result: {first_content.text}"
async with Client(server) as client:
result = await client.call_tool("outer_tool", {"x": 5})
assert "Got result: 10" in str(result)
async def test_tool_can_call_another_tool_with_custom_ttl(self):
"""A tool can call another tool as a background task with custom TTL."""
server = FastMCP("test")
@server.tool(task=True)
async def inner_tool(x: int) -> int:
return x * 2
@server.tool
async def outer_tool(x: int) -> str:
custom_ttl = 45000 # 45 seconds
result = await server.call_tool(
"inner_tool", {"x": x}, task_meta=TaskMeta(ttl=custom_ttl)
)
return f"Task TTL: {result.task.ttl}"
async with Client(server) as client:
result = await client.call_tool("outer_tool", {"x": 5})
# The inner tool task should have the custom TTL
assert "Task TTL: 45000" in str(result)