mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
A FastMCP client now transparently completes tasked tools/call: the tasks ClientExtension advertises the capability and claims the CreateTaskResult, and the resolver drives the tasks/get poll loop to completion, answering in-task input through the client's elicitation handler and returning the tool's real result. call_tool is transparent, call_tool_mcp exposes the raw result, and call_tool_task yields a Task handle. The client half moves to fastmcp-tasks; the [tasks] client extension auto-wires into Client (ProxyClient opts out). Co-Authored-By: Claude <noreply@anthropic.com>
61 lines
2.3 KiB
Python
61 lines
2.3 KiB
Python
"""Fallback poll cadence for client-side task waiting (SEP-2663).
|
|
|
|
The modern protocol has no task status notifications, so the client polls. The
|
|
backoff ramps from a fast floor, doubling up to a ceiling: the server-advertised
|
|
``pollIntervalMs`` when present (a statement about server load), else the client
|
|
``poll_interval`` setting. A quick task resolves in ~20ms; a long one settles to
|
|
the advertised cadence.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastmcp_tasks.client import MIN_POLL_INTERVAL, _next_poll_delay, _poll_ceiling
|
|
from fastmcp_tasks.settings import TasksClientSettings, client_settings
|
|
from pydantic import ValidationError
|
|
|
|
|
|
@pytest.mark.parametrize("value", [0, -0.5, -1])
|
|
def test_non_positive_poll_interval_setting_is_rejected(value: float):
|
|
with pytest.raises(ValidationError):
|
|
TasksClientSettings(poll_interval=value)
|
|
|
|
|
|
def test_positive_poll_interval_setting_is_accepted():
|
|
settings = TasksClientSettings(poll_interval=0.25)
|
|
assert settings.poll_interval == 0.25
|
|
|
|
|
|
@pytest.mark.parametrize("poll_interval_ms", [2000, 30_000])
|
|
def test_advertised_interval_caps_the_ramp(poll_interval_ms: int):
|
|
"""An advertised interval is the ceiling the ramp tops out at."""
|
|
assert _poll_ceiling(poll_interval_ms) == poll_interval_ms / 1000
|
|
|
|
|
|
def test_large_advertised_interval_is_honored():
|
|
day_ms = 24 * 60 * 60 * 1000
|
|
assert _poll_ceiling(day_ms) == 24 * 60 * 60
|
|
|
|
|
|
@pytest.mark.parametrize("poll_interval_ms", [None, 0, -1, -5000])
|
|
def test_absent_or_hostile_interval_falls_back_to_setting(poll_interval_ms):
|
|
"""An absent, zero, or negative server value cannot spin the client: use the setting."""
|
|
assert _poll_ceiling(poll_interval_ms) == client_settings.poll_interval
|
|
|
|
|
|
def test_ramp_doubles_from_floor_up_to_advertised_ceiling():
|
|
"""Even with an advertised interval, the poll ramps fast then caps at it."""
|
|
ceiling_ms = 500 # 0.5s ceiling
|
|
delays = []
|
|
backoff = MIN_POLL_INTERVAL
|
|
for _ in range(7):
|
|
delay, backoff = _next_poll_delay(ceiling_ms, backoff)
|
|
delays.append(delay)
|
|
|
|
assert delays == [0.02, 0.04, 0.08, 0.16, 0.32, 0.5, 0.5]
|
|
|
|
|
|
def test_first_delay_is_the_floor():
|
|
delay, backoff = _next_poll_delay(30_000, MIN_POLL_INTERVAL)
|
|
assert delay == MIN_POLL_INTERVAL
|
|
assert backoff == MIN_POLL_INTERVAL * 2
|