60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
340 lines
10 KiB
Python
340 lines
10 KiB
Python
"""
|
|
Tests for Task result caching behavior.
|
|
|
|
Verifies that Task.result() and await task cache results properly to avoid
|
|
redundant server calls and ensure consistent object identity.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.client import Client
|
|
|
|
|
|
async def test_tool_task_result_cached_on_first_call():
|
|
"""First call caches result, subsequent calls return cached value."""
|
|
call_count = 0
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(task=True)
|
|
async def counting_tool() -> int:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return call_count
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("counting_tool", task=True)
|
|
|
|
result1 = await task.result()
|
|
result2 = await task.result()
|
|
result3 = await task.result()
|
|
|
|
# All should return 1 (first execution 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_prompt_task_result_cached():
|
|
"""PromptTask caches results on first call."""
|
|
call_count = 0
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.prompt(task=True)
|
|
async def counting_prompt() -> str:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return f"Call number: {call_count}"
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.get_prompt("counting_prompt", task=True)
|
|
|
|
result1 = await task.result()
|
|
result2 = await task.result()
|
|
result3 = await task.result()
|
|
|
|
# All should return same content
|
|
assert result1.messages[0].content.text == "Call number: 1"
|
|
assert result2.messages[0].content.text == "Call number: 1"
|
|
assert result3.messages[0].content.text == "Call number: 1"
|
|
|
|
# Verify they're the same object (cached)
|
|
assert result1 is result2 is result3
|
|
|
|
|
|
async def test_resource_task_result_cached():
|
|
"""ResourceTask caches results on first call."""
|
|
call_count = 0
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.resource("file://counter.txt", task=True)
|
|
async def counting_resource() -> str:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return f"Count: {call_count}"
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.read_resource("file://counter.txt", task=True)
|
|
|
|
result1 = await task.result()
|
|
result2 = await task.result()
|
|
result3 = await task.result()
|
|
|
|
# All should return same content
|
|
assert result1[0].text == "Count: 1"
|
|
assert result2[0].text == "Count: 1"
|
|
assert result3[0].text == "Count: 1"
|
|
|
|
# Verify they're the same object (cached)
|
|
assert result1 is result2 is result3
|
|
|
|
|
|
async def test_multiple_await_returns_same_object():
|
|
"""Multiple await task calls return identical object."""
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(task=True)
|
|
async def sample_tool() -> str:
|
|
return "result"
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("sample_tool", task=True)
|
|
|
|
result1 = await task
|
|
result2 = await task
|
|
result3 = await task
|
|
|
|
# Should be exact same object in memory
|
|
assert result1 is result2 is result3
|
|
assert id(result1) == id(result2) == id(result3)
|
|
|
|
|
|
async def test_result_and_await_share_cache():
|
|
"""task.result() and await task share the same cache."""
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(task=True)
|
|
async def sample_tool() -> str:
|
|
return "cached"
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("sample_tool", task=True)
|
|
|
|
# Call result() first
|
|
result_via_method = await task.result()
|
|
|
|
# Then await directly
|
|
result_via_await = await task
|
|
|
|
# Should be the same cached object
|
|
assert result_via_method is result_via_await
|
|
assert id(result_via_method) == id(result_via_await)
|
|
|
|
|
|
async def test_forbidden_mode_tool_caches_error_result():
|
|
"""Tools with task=False (mode=forbidden) cache error results."""
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(task=False)
|
|
async def non_task_tool() -> int:
|
|
return 1
|
|
|
|
async with Client(mcp) as client:
|
|
# Request as task, but mode="forbidden" will reject with error
|
|
task = await client.call_tool("non_task_tool", task=True, raise_on_error=False)
|
|
|
|
# Should be immediate (error returned immediately)
|
|
assert task.returned_immediately
|
|
|
|
result1 = await task.result()
|
|
result2 = await task.result()
|
|
result3 = await task.result()
|
|
|
|
# All should return cached error
|
|
assert result1.is_error
|
|
assert "does not support task-augmented execution" in str(result1)
|
|
|
|
# Verify they're the same object (cached)
|
|
assert result1 is result2 is result3
|
|
|
|
|
|
@pytest.mark.xfail(
|
|
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
|
|
"ReadResourceRequestParams; prompt/resource task submission is not "
|
|
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
|
|
strict=True,
|
|
)
|
|
async def test_forbidden_mode_prompt_raises_error():
|
|
"""Prompts with task=False (mode=forbidden) raise error."""
|
|
import pytest
|
|
from mcp.shared.exceptions import MCPError
|
|
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.prompt(task=False)
|
|
async def non_task_prompt() -> str:
|
|
return "Immediate"
|
|
|
|
async with Client(mcp) as client:
|
|
# Prompts with mode="forbidden" raise MCPError when called with task=True
|
|
with pytest.raises(MCPError):
|
|
await client.get_prompt("non_task_prompt", task=True)
|
|
|
|
|
|
@pytest.mark.xfail(
|
|
reason="SDK v2 has no `task` field on GetPromptRequestParams / "
|
|
"ReadResourceRequestParams; prompt/resource task submission is not "
|
|
"wire-expressible and always graceful-degrades (sdk-feedback #3).",
|
|
strict=True,
|
|
)
|
|
async def test_forbidden_mode_resource_raises_error():
|
|
"""Resources with task=False (mode=forbidden) raise error."""
|
|
import pytest
|
|
from mcp.shared.exceptions import MCPError
|
|
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.resource("file://immediate.txt", task=False)
|
|
async def non_task_resource() -> str:
|
|
return "Immediate"
|
|
|
|
async with Client(mcp) as client:
|
|
# Resources with mode="forbidden" raise MCPError when called with task=True
|
|
with pytest.raises(MCPError):
|
|
await client.read_resource("file://immediate.txt", task=True)
|
|
|
|
|
|
async def test_immediate_task_caches_result():
|
|
"""Immediate tasks (optional mode called without background) cache results."""
|
|
call_count = 0
|
|
mcp = FastMCP("test", tasks=True)
|
|
|
|
# Tool with task=True (optional mode) - but without docket will execute immediately
|
|
@mcp.tool(task=True)
|
|
async def task_tool() -> int:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return call_count
|
|
|
|
async with Client(mcp) as client:
|
|
# 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()
|
|
|
|
# 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():
|
|
"""Cache works correctly when mixing result() and await."""
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(task=True)
|
|
async def mixed_tool() -> str:
|
|
return "mixed"
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("mixed_tool", task=True)
|
|
|
|
# Access in various orders
|
|
result1 = await task
|
|
result2 = await task.result()
|
|
result3 = await task
|
|
result4 = await task.result()
|
|
|
|
# All should be the same cached object
|
|
assert result1 is result2 is result3 is result4
|
|
|
|
|
|
async def test_different_tasks_have_separate_caches():
|
|
"""Different task instances maintain separate caches."""
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(task=True)
|
|
async def separate_tool(value: str) -> str:
|
|
return f"Result: {value}"
|
|
|
|
async with Client(mcp) as client:
|
|
task1 = await client.call_tool("separate_tool", {"value": "A"}, task=True)
|
|
task2 = await client.call_tool("separate_tool", {"value": "B"}, task=True)
|
|
|
|
result1 = await task1.result()
|
|
result2 = await task2.result()
|
|
|
|
# Different results
|
|
assert result1.data == "Result: A"
|
|
assert result2.data == "Result: B"
|
|
|
|
# Not the same object
|
|
assert result1 is not result2
|
|
|
|
# But each task's cache works independently
|
|
result1_again = await task1.result()
|
|
result2_again = await task2.result()
|
|
|
|
assert result1 is result1_again
|
|
assert result2 is result2_again
|
|
|
|
|
|
async def test_cache_survives_status_checks():
|
|
"""Calling status() doesn't affect result caching."""
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(task=True)
|
|
async def status_check_tool() -> str:
|
|
return "status"
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("status_check_tool", task=True)
|
|
|
|
# Check status multiple times
|
|
await task.status()
|
|
await task.status()
|
|
|
|
result1 = await task.result()
|
|
|
|
# Check status again
|
|
await task.status()
|
|
|
|
result2 = await task.result()
|
|
|
|
# Cache should still work
|
|
assert result1 is result2
|
|
|
|
|
|
async def test_cache_survives_wait_calls():
|
|
"""Calling wait() doesn't affect result caching."""
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(task=True)
|
|
async def wait_test_tool() -> str:
|
|
return "waited"
|
|
|
|
async with Client(mcp) as client:
|
|
task = await client.call_tool("wait_test_tool", task=True)
|
|
|
|
# Wait for completion
|
|
await task.wait()
|
|
|
|
result1 = await task.result()
|
|
|
|
# Wait again (no-op since completed)
|
|
await task.wait()
|
|
|
|
result2 = await task.result()
|
|
|
|
# Cache should still work
|
|
assert result1 is result2
|