diff --git a/.github/actions/run-pytest/action.yml b/.github/actions/run-pytest/action.yml index ff429a4cc..b1a2f3017 100644 --- a/.github/actions/run-pytest/action.yml +++ b/.github/actions/run-pytest/action.yml @@ -38,6 +38,7 @@ runs: uv run --no-sync pytest \ --inline-snapshot=disable \ --timeout=$TIMEOUT \ + --durations=50 \ -m "$MARKER" \ $PARALLEL_FLAGS \ $EXTRA_FLAGS \ diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 94cbbe169..17fb7be90 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -336,6 +336,11 @@ class Client( elicitation_handler ) + # Maximum time to wait for a clean disconnect before giving up. + # Normally disconnects complete in <100ms; this is a safety net for + # unresponsive servers. + self._disconnect_timeout: float = fastmcp.settings.client_disconnect_timeout + # Session context management - see class docstring for detailed explanation self._session_state = ClientSessionState() @@ -511,7 +516,7 @@ class Client( # Use a timeout to prevent hanging during cleanup if the connection is in a bad # state (e.g., rate-limited). The MCP SDK's transport may try to terminate the # session which can hang if the server is unresponsive. - with anyio.move_on_after(5): + with anyio.move_on_after(self._disconnect_timeout): await self._disconnect() async def _connect(self): diff --git a/src/fastmcp/server/mixins/lifespan.py b/src/fastmcp/server/mixins/lifespan.py index 583944d61..a6c0cb9ed 100644 --- a/src/fastmcp/server/mixins/lifespan.py +++ b/src/fastmcp/server/mixins/lifespan.py @@ -105,6 +105,7 @@ class LifespanMixin: "concurrency": settings.docket.concurrency, "redelivery_timeout": settings.docket.redelivery_timeout, "reconnection_delay": settings.docket.reconnection_delay, + "minimum_check_interval": settings.docket.minimum_check_interval, } if settings.docket.worker_name: worker_kwargs["name"] = settings.docket.worker_name diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index d98d7ed00..393b1ff2f 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -117,6 +117,21 @@ class DocketSettings(BaseSettings): ), ] = timedelta(seconds=5) + minimum_check_interval: Annotated[ + timedelta, + Field( + description=inspect.cleandoc( + """ + How frequently the worker polls for new tasks. Lower + values reduce latency for task pickup at the cost of + more CPU usage. The default of 50ms is a good balance; + increase for high-volume production deployments where + tasks are long-running. + """ + ), + ), + ] = timedelta(milliseconds=50) + class Settings(BaseSettings): """FastMCP settings.""" @@ -231,6 +246,13 @@ class Settings(BaseSettings): ), ] = None + client_disconnect_timeout: Annotated[ + float, + Field( + description="Maximum time to wait for a clean disconnect before giving up, in seconds.", + ), + ] = 5 + # Transport settings transport: Literal["stdio", "http", "sse", "streamable-http"] = "stdio" diff --git a/tests/client/tasks/conftest.py b/tests/client/tasks/conftest.py index d0921e057..29d0c9a10 100644 --- a/tests/client/tasks/conftest.py +++ b/tests/client/tasks/conftest.py @@ -1,15 +1 @@ -"""Configuration for client task tests. - -Task tests require Docket infrastructure (Redis-backed task queue) which can -take significant time to initialize, especially under parallel test execution. -The default 5s timeout is too tight for these tests. -""" - -import pytest - - -def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: - """Increase timeout for task tests that need Docket infrastructure.""" - for item in items: - if not item.get_closest_marker("timeout"): - item.add_marker(pytest.mark.timeout(15)) +"""Configuration for client task tests.""" diff --git a/tests/conftest.py b/tests/conftest.py index 1401d4141..1edf55c60 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,10 @@ import asyncio import logging +import secrets import socket import sys from collections.abc import Callable, Generator +from datetime import timedelta from pathlib import Path from typing import Any @@ -60,11 +62,20 @@ def isolate_settings_home(tmp_path: Path): This prevents file locking issues when multiple tests share the same storage directory in settings.home / "oauth-proxy". + + Also sets a fast Docket polling interval for tests — the default 50ms + is fine for production but still adds ~25ms average pickup latency per + task. 10ms makes task tests near-instant. """ test_home = tmp_path / "fastmcp-test-home" test_home.mkdir(exist_ok=True) - with temporary_settings(home=test_home): + with temporary_settings( + home=test_home, + docket__minimum_check_interval=timedelta(milliseconds=10), + docket__url=f"memory://{secrets.token_hex(4)}", + client_disconnect_timeout=1, + ): yield diff --git a/tests/server/tasks/conftest.py b/tests/server/tasks/conftest.py index 7d48204bb..496053bfa 100644 --- a/tests/server/tasks/conftest.py +++ b/tests/server/tasks/conftest.py @@ -1,15 +1 @@ -"""Configuration for server task tests. - -Task tests require Docket infrastructure (Redis-backed task queue) which can -take significant time to initialize, especially under parallel test execution. -The default 5s timeout is too tight for these tests. -""" - -import pytest - - -def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: - """Increase timeout for task tests that need Docket infrastructure.""" - for item in items: - if not item.get_closest_marker("timeout"): - item.add_marker(pytest.mark.timeout(15)) +"""Configuration for server task tests.""" diff --git a/tests/server/tasks/test_server_tasks_parameter.py b/tests/server/tasks/test_server_tasks_parameter.py index 8bb0ec6c8..af3d16641 100644 --- a/tests/server/tasks/test_server_tasks_parameter.py +++ b/tests/server/tasks/test_server_tasks_parameter.py @@ -6,10 +6,13 @@ components (tools, prompts, resources), and that explicit component-level settings properly override the server default. """ +import pytest + from fastmcp import FastMCP from fastmcp.client import Client +@pytest.mark.timeout(10) async def test_server_tasks_true_defaults_all_components(): """Server with tasks=True makes all components default to supporting tasks.""" mcp = FastMCP("test", tasks=True) diff --git a/tests/server/tasks/test_task_methods.py b/tests/server/tasks/test_task_methods.py index 9dd4d7a62..07bef01ce 100644 --- a/tests/server/tasks/test_task_methods.py +++ b/tests/server/tasks/test_task_methods.py @@ -174,6 +174,7 @@ async def test_task_cancellation_workflow(endpoint_server): assert status.status == "cancelled" +@pytest.mark.timeout(10) async def test_task_cancellation_interrupts_running_coroutine(endpoint_server): """Task cancellation actually interrupts the running coroutine. diff --git a/tests/server/tasks/test_task_mount.py b/tests/server/tasks/test_task_mount.py index b601792e2..b20cc4d32 100644 --- a/tests/server/tasks/test_task_mount.py +++ b/tests/server/tasks/test_task_mount.py @@ -151,6 +151,7 @@ class TestMountedToolTasks: status = await task.status() assert status.status == "completed" + @pytest.mark.timeout(10) async def test_mounted_tool_task_cancellation(self, parent_server): """Can cancel a mounted tool task.""" async with Client(parent_server) as client: diff --git a/tests/server/tasks/test_task_proxy.py b/tests/server/tasks/test_task_proxy.py index f1b41a3dc..ce20a9a7b 100644 --- a/tests/server/tasks/test_task_proxy.py +++ b/tests/server/tasks/test_task_proxy.py @@ -17,7 +17,7 @@ from mcp.types import TextContent, TextResourceContents from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport -from fastmcp.server.providers.proxy import ProxyClient +from fastmcp.server import create_proxy @pytest.fixture @@ -60,7 +60,7 @@ def backend_server() -> FastMCP: @pytest.fixture def proxy_server(backend_server: FastMCP) -> FastMCP: """Create a proxy server that forwards to the backend.""" - return FastMCP.as_proxy(ProxyClient(transport=FastMCPTransport(backend_server))) + return create_proxy(FastMCPTransport(backend_server)) class TestProxyToolsSyncExecution: