diff --git a/loq.toml b/loq.toml index ddf57804b2..dcbd5b57c9 100644 --- a/loq.toml +++ b/loq.toml @@ -296,7 +296,7 @@ max_lines = 713 [[rules]] path = "src/fastmcp/client/transports.py" -max_lines = 1186 +max_lines = 1205 [[rules]] path = "docs/docs.json" diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 8365d4dce0..a112e332a2 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -25,7 +25,7 @@ from mcp.client.stdio import stdio_client from mcp.client.streamable_http import streamable_http_client from mcp.server.fastmcp import FastMCP as FastMCP1Server -from mcp.shared._httpx_utils import McpHttpClientFactory +from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client from mcp.shared.memory import create_client_server_memory_streams from pydantic import AnyUrl from typing_extensions import TypedDict, Unpack @@ -208,6 +208,19 @@ def __init__( sse_read_timeout: datetime.timedelta | float | int | None = None, httpx_client_factory: McpHttpClientFactory | None = None, ): + """Initialize a Streamable HTTP transport. + + Args: + url: The MCP server endpoint URL. + headers: Optional headers to include in requests. + auth: Authentication method - httpx.Auth, "oauth" for OAuth flow, + or a bearer token string. + sse_read_timeout: Deprecated. Use read_timeout_seconds in session_kwargs. + httpx_client_factory: Optional factory for creating httpx.AsyncClient. + If provided, must accept keyword arguments: headers, auth, + follow_redirects, and optionally timeout. Using **kwargs is + recommended to ensure forward compatibility. + """ if isinstance(url, AnyUrl): url = str(url) if not isinstance(url, str) or not url.startswith("http"): @@ -253,25 +266,31 @@ async def connect_session( # need to be forwarded to the remote server. headers = get_http_headers() | self.headers - # Build httpx client configuration - httpx_client_kwargs: dict[str, Any] = { - "headers": headers, - "auth": self.auth, - "follow_redirects": True, - } - - # Configure timeout if provided (convert timedelta to seconds for httpx) + # Configure timeout if provided, preserving MCP's 30s connect default + timeout: httpx.Timeout | None = None if session_kwargs.get("read_timeout_seconds") is not None: read_timeout_seconds = cast( datetime.timedelta, session_kwargs.get("read_timeout_seconds") ) - httpx_client_kwargs["timeout"] = read_timeout_seconds.total_seconds() + timeout = httpx.Timeout(30.0, read=read_timeout_seconds.total_seconds()) - # Create httpx client from factory or use default + # Create httpx client from factory or use default with MCP-appropriate timeouts + # create_mcp_http_client uses 30s connect/5min read timeout by default, + # and always enables follow_redirects if self.httpx_client_factory is not None: - http_client = self.httpx_client_factory(**httpx_client_kwargs) + # Factory clients get the full kwargs for backwards compatibility + http_client = self.httpx_client_factory( + headers=headers, + auth=self.auth, + follow_redirects=True, # type: ignore[call-arg] + **({"timeout": timeout} if timeout else {}), + ) else: - http_client = httpx.AsyncClient(**httpx_client_kwargs) + http_client = create_mcp_http_client( + headers=headers, + timeout=timeout, + auth=self.auth, + ) # Ensure httpx client is closed after use async with ( diff --git a/tests/integration_tests/test_timeout_fix.py b/tests/integration_tests/test_timeout_fix.py new file mode 100644 index 0000000000..27776c1bac --- /dev/null +++ b/tests/integration_tests/test_timeout_fix.py @@ -0,0 +1,51 @@ +"""Test that verifies the timeout fix for issue #2842 and #2845.""" + +import asyncio + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.utilities.tests import run_server_async + + +def create_test_server() -> FastMCP: + """Create a FastMCP server with a slow tool.""" + server = FastMCP("TestServer") + + @server.tool + async def slow_tool(duration: int = 6) -> str: + """A tool that takes some time to complete.""" + await asyncio.sleep(duration) + return f"Completed in {duration} seconds" + + return server + + +@pytest.fixture +async def streamable_http_server(): + """Start a test server and return its URL.""" + server = create_test_server() + async with run_server_async(server) as url: + yield url + + +@pytest.mark.integration +@pytest.mark.timeout(15) +async def test_slow_tool_with_http_transport(streamable_http_server: str): + """Test that tools taking >5 seconds work correctly with HTTP transport. + + This test verifies the fix for: + - Issue #2842: Client can't get result after upgrading to 2.14.2 + - Issue #2845: Server doesn't return results when tool takes >5 seconds + + The root cause was that the httpx client was created without explicit + timeout configuration, defaulting to httpx's 5-second timeout. + """ + async with Client( + transport=StreamableHttpTransport(streamable_http_server) + ) as client: + # This should NOT timeout since we fixed the default timeout + result = await client.call_tool("slow_tool", {"duration": 6}) + assert result.data == "Completed in 6 seconds"