Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion loq.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
45 changes: 32 additions & 13 deletions src/fastmcp/client/transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Ensure httpx client is closed after use
async with (
Expand Down
51 changes: 51 additions & 0 deletions tests/integration_tests/test_timeout_fix.py
Original file line number Diff line number Diff line change
@@ -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})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent test timeout from 6s tool duration

This test exercises a 6‑second tool call, but the repo’s pytest configuration sets a default timeout of 5 seconds (pyproject.tomltimeout = 5), so the test will time out under the normal test run even when the fix works. That makes CI fail for the new test in any environment where pytest-timeout is enabled (as it is here). Consider adding @pytest.mark.timeout(10) or shortening the duration so the test completes within the global timeout.

Useful? React with 👍 / 👎.

assert result.data == "Completed in 6 seconds"