fastmcp/tests/tasks/server/test_task_ttl.py
Chris Guidry 1ac8fc6060
Encrypt task context snapshots at rest (#4772)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 20:01:40 -04:00

125 lines
4.2 KiB
Python

"""TTL handling for SEP-2663 tasks.
Servers report `ttlMs` in the create result and in every `tasks/get` response —
while the task is working and after it completes — using Docket's default
execution TTL (900000 ms) when none is configured.
"""
from __future__ import annotations
import asyncio
from fastmcp import FastMCP
from fastmcp_tasks import TasksExtension
from tests.tasks.task_helpers import (
get_task,
running_task_server,
submit_task,
wait_for_task,
)
# Docket's default execution_ttl is 900 seconds.
DEFAULT_TTL_MS = 900000
def _ttl_server() -> FastMCP:
mcp = FastMCP("keepalive-test")
mcp.add_extension(TasksExtension())
@mcp.tool(task=True)
async def quick_task(value: int) -> int:
return value * 2
@mcp.tool(task=True)
async def slow_task() -> str:
# Never completes during the test; the test only checks status/TTL while
# the task is still working, so a suspended coroutine is enough.
await asyncio.Event().wait()
return "done"
return mcp
async def test_ttl_returned_while_working():
"""ttlMs is present in the create result and in tasks/get while working."""
mcp = _ttl_server()
async with running_task_server(mcp):
created = await submit_task(mcp, "slow_task", {})
assert created.ttl_ms == DEFAULT_TTL_MS
got = await get_task(mcp, created.task_id)
assert got.status == "working"
assert got.ttl_ms == DEFAULT_TTL_MS
async def test_ttl_returned_after_completion():
"""ttlMs is present in tasks/get after the task completes."""
mcp = _ttl_server()
async with running_task_server(mcp):
created = await submit_task(mcp, "quick_task", {"value": 5})
final = await wait_for_task(mcp, created.task_id)
assert final.status == "completed"
assert final.ttl_ms == DEFAULT_TTL_MS
async def test_default_ttl_when_unspecified():
"""The server applies Docket's default TTL when none is configured."""
mcp = _ttl_server()
async with running_task_server(mcp):
created = await submit_task(mcp, "quick_task", {"value": 3})
assert created.ttl_ms == DEFAULT_TTL_MS
got = await get_task(mcp, created.task_id)
assert got.ttl_ms == DEFAULT_TTL_MS
async def test_poll_refreshes_routing_key_ttl():
"""A poll extends the current-leg pointer's TTL (sliding expiration).
A leg that runs longer than the pointer's wall-clock TTL would otherwise
strand `_lookup_task` on the base leg. Polling must keep the routing keys
alive: after shrinking the pointer's TTL, a `tasks/get` restores it.
"""
from fastmcp_tasks.input_store import _current_leg_key
mcp = _ttl_server()
async with running_task_server(mcp):
created = await submit_task(mcp, "slow_task", {})
docket = mcp._docket
assert docket is not None
key = _current_leg_key(docket, None, created.task_id)
async with docket.redis() as redis:
await redis.expire(key, 5)
assert await redis.ttl(key) <= 5
await get_task(mcp, created.task_id)
async with docket.redis() as redis:
# Refreshed well past the shrunk 5s, back toward the full window.
assert await redis.ttl(key) > 60
async def test_poll_refreshes_snapshot_ttl():
"""A poll extends the context snapshot's TTL alongside the routing keys.
A re-entered leg restores the submitting caller from the snapshot, so an
actively polled task must never outlive it: without encryption an expired
snapshot degrades the leg to an anonymous run, and with encryption it fails
the task. After shrinking the snapshot's TTL, a `tasks/get` restores it.
"""
from fastmcp_tasks.context import _snapshot_redis_key
mcp = _ttl_server()
async with running_task_server(mcp):
created = await submit_task(mcp, "slow_task", {})
docket = mcp._docket
assert docket is not None
key = _snapshot_redis_key(docket, None, created.task_id)
async with docket.redis() as redis:
await redis.expire(key, 5)
assert await redis.ttl(key) <= 5
await get_task(mcp, created.task_id)
async with docket.redis() as redis:
assert await redis.ttl(key) > 60