fastmcp/tests/tasks/client/test_poll_interval.py
Jeremiah Lowin 5fa2883670
Implement SEP-2663 tasks extension: TasksExtension, poll-based task lifecycle
TasksExtension serves io.modelcontextprotocol/tasks on the extension API:
a decide-and-task tools/call interceptor (era-gated to modern connections),
tasks/get with inlined results and inputRequests, tasks/update delivering
poll-based in-task elicitation, tasks/cancel, durable creation, and
auth-scoped task isolation. Wire models validate against the vendored
ext-tasks schema. Worker-side Context hooks are refcounted so sibling
servers cannot strand each other's workers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 23:00:38 -04:00

92 lines
3 KiB
Python

"""Fallback poll cadence for client-side task waiting.
Two modes: a server-advertised pollInterval is honored exactly, while an
unadvertised one falls back to an exponential ramp up to the client setting.
"""
import pytest
from fastmcp_tasks.client import MIN_POLL_INTERVAL, ToolTask
from mcp_types import GetTaskResult
from pydantic import ValidationError
from fastmcp import Client, FastMCP
from fastmcp.settings import Settings
from fastmcp.utilities.tests import temporary_settings
pytestmark = pytest.mark.skip(reason="Phase 4: requires client task support (SEP-2663)")
@pytest.mark.parametrize("value", [0, -0.5, -1])
def test_non_positive_poll_interval_setting_is_rejected(value: float):
with pytest.raises(ValidationError):
Settings(client_task_poll_interval=value)
def test_positive_poll_interval_setting_is_accepted():
settings = Settings(client_task_poll_interval=0.25)
assert settings.client_task_poll_interval == 0.25
@pytest.fixture
def task() -> ToolTask:
client = Client(FastMCP())
return ToolTask(client=client, task_id="t1", tool_name="echo")
def _status(poll_interval: int | None) -> GetTaskResult:
return GetTaskResult(
task_id="t1",
status="working",
created_at="2026-01-01T00:00:00+00:00",
last_updated_at="2026-01-01T00:00:00+00:00",
ttl=None,
poll_interval=poll_interval,
)
@pytest.mark.parametrize("poll_interval", [2000, 30_000])
def test_advertised_interval_is_used_verbatim_without_backoff(
task: ToolTask, poll_interval: int
):
"""An advertised interval is the delay itself, not a ceiling to ramp toward."""
task._status_cache = _status(poll_interval)
expected = poll_interval / 1000
backoff = MIN_POLL_INTERVAL
for _ in range(5):
delay, backoff = task._next_poll_delay(backoff)
assert delay == expected
def test_large_advertised_interval_is_honored(task: ToolTask):
task._status_cache = _status(24 * 60 * 60 * 1000)
delay, _ = task._next_poll_delay(MIN_POLL_INTERVAL)
assert delay == 24 * 60 * 60
@pytest.mark.parametrize("poll_interval", [0, -1, -5000])
def test_non_positive_advertised_interval_is_floored(
task: ToolTask, poll_interval: int
):
"""A buggy or hostile server must not be able to spin the client."""
task._status_cache = _status(poll_interval)
delay, _ = task._next_poll_delay(MIN_POLL_INTERVAL)
assert delay == MIN_POLL_INTERVAL
def test_unadvertised_interval_ramps_up_to_setting(task: ToolTask):
task._status_cache = _status(None)
with temporary_settings(client_task_poll_interval=0.5):
delays = []
backoff = MIN_POLL_INTERVAL
for _ in range(7):
delay, backoff = task._next_poll_delay(backoff)
delays.append(delay)
assert delays == [0.02, 0.04, 0.08, 0.16, 0.32, 0.5, 0.5]
def test_missing_status_cache_ramps_from_floor(task: ToolTask):
delay, backoff = task._next_poll_delay(MIN_POLL_INTERVAL)
assert delay == MIN_POLL_INTERVAL
assert backoff == MIN_POLL_INTERVAL * 2