Skip to content

Commit cefd1b1

Browse files
authored
Merge pull request #369 from nancyjlau/nancyjlau/streamable-http-timeout
Fix streamable HTTP timeout handling
2 parents 052e4a6 + 91296c3 commit cefd1b1

4 files changed

Lines changed: 98 additions & 13 deletions

File tree

hud/environment/connection.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@ async def connect(self) -> None:
159159
"transport": self._transport,
160160
"auth": self._auth,
161161
}
162+
client_timeout = getattr(self._transport, "_hud_client_timeout", None)
163+
if client_timeout is not None:
164+
client_kwargs["timeout"] = client_timeout
162165
if self._elicitation_handler is not None:
163166
client_kwargs["elicitation_handler"] = self._elicitation_handler
164167

hud/environment/connectors/mcp_config.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING, Any
5+
from typing import TYPE_CHECKING, Any, cast
66

77
from hud.environment.connectors.base import BaseConnectorMixin
88

@@ -66,8 +66,7 @@ def connect_mcp(
6666
if settings.client_timeout > 0
6767
else min(request_timeout, settings.__class__.model_fields["client_timeout"].default)
6868
)
69-
server_config.setdefault("sse_read_timeout", timeout)
70-
transport = _build_transport(server_config)
69+
transport = _build_transport(server_config, timeout=timeout)
7170

7271
return self._add_connection(
7372
name,
@@ -121,17 +120,29 @@ def connect_mcp_config(
121120
return self
122121

123122

124-
def _build_transport(server_config: dict[str, Any]) -> Any:
123+
def _build_transport(server_config: dict[str, Any], *, timeout: float | None = None) -> Any:
125124
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
126125
from fastmcp.mcp_config import infer_transport_type_from_url
127126

128127
url = server_config["url"]
129128
transport_type = server_config.get("transport") or infer_transport_type_from_url(url)
130-
transport_cls = SSETransport if transport_type == "sse" else StreamableHttpTransport
131-
132-
return transport_cls(
133-
url=url,
134-
headers=server_config.get("headers"),
135-
auth=server_config.get("auth"),
136-
sse_read_timeout=server_config.get("sse_read_timeout"),
137-
)
129+
transport_timeout = timeout if timeout is not None else server_config.get("sse_read_timeout")
130+
transport_kwargs = {
131+
"url": url,
132+
"headers": server_config.get("headers"),
133+
"auth": server_config.get("auth"),
134+
"httpx_client_factory": server_config.get("httpx_client_factory"),
135+
}
136+
137+
if transport_type == "sse":
138+
return SSETransport(
139+
**transport_kwargs,
140+
sse_read_timeout=transport_timeout,
141+
)
142+
143+
transport = StreamableHttpTransport(**transport_kwargs)
144+
if transport_timeout is not None:
145+
# FastMCP 3.x wants streamable HTTP timeouts on the client/session,
146+
# not on the transport constructor.
147+
cast("Any", transport)._hud_client_timeout = transport_timeout
148+
return transport

hud/environment/tests/test_connection.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,35 @@ async def test_connect_creates_client(self) -> None:
140140
# Client is now set
141141
assert connector.client is mock_client
142142

143+
@pytest.mark.asyncio
144+
async def test_connect_passes_transport_timeout_to_client(self) -> None:
145+
"""connect() forwards transport timeout to FastMCP client session kwargs."""
146+
147+
class Transport:
148+
_hud_client_timeout = 300
149+
150+
transport = Transport()
151+
connector = Connector(
152+
transport=transport,
153+
config=ConnectionConfig(),
154+
name="test",
155+
connection_type=ConnectionType.REMOTE,
156+
auth="test-token",
157+
)
158+
159+
mock_client = MagicMock()
160+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
161+
mock_client.is_connected = MagicMock(return_value=True)
162+
163+
with patch("fastmcp.client.Client", return_value=mock_client) as mock_cls:
164+
await connector.connect()
165+
166+
mock_cls.assert_called_once_with(
167+
transport=transport,
168+
auth="test-token",
169+
timeout=300,
170+
)
171+
143172
@pytest.mark.asyncio
144173
async def test_disconnect_clears_client(self) -> None:
145174
"""disconnect() closes client and clears state."""

hud/environment/tests/test_connectors.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,53 @@ def mount(self, server: Any, *, prefix: str | None = None) -> None:
197197
env = TestEnv()
198198
with patch("hud.settings.settings", spec=Settings) as mock_settings:
199199
mock_settings.hud_mcp_url = "https://mcp.hud.ai"
200-
mock_settings.client_timeout = 300 # Used in connect_mcp for sse_read_timeout
200+
mock_settings.client_timeout = 300 # Used in connect_mcp transport timeout logic
201201

202202
env.connect_hub("browser")
203203

204204
# connect_hub creates a connection named "hud" (from mcp_config key)
205205
assert "hud" in env._connections
206206
# Verify hub config is stored for serialization
207207
assert env._hub_config == {"name": "browser"}
208+
209+
def test_connect_mcp_streamable_transport_uses_client_timeout(self) -> None:
210+
"""Streamable HTTP uses FastMCP client timeout instead of deprecated transport arg."""
211+
from fastmcp.client.transports import StreamableHttpTransport
212+
213+
from hud.environment.connectors.mcp_config import MCPConfigConnectorMixin
214+
from hud.settings import Settings
215+
216+
class TestEnv(MCPConfigConnectorMixin):
217+
def __init__(self) -> None:
218+
self._connections: dict[str, Connector] = {}
219+
220+
env = TestEnv()
221+
with patch("hud.settings.settings", spec=Settings) as mock_settings:
222+
mock_settings.client_timeout = 300
223+
env.connect_mcp({"browser": {"url": "https://mcp.hud.ai/browser"}})
224+
225+
transport = env._connections["browser"]._transport
226+
assert isinstance(transport, StreamableHttpTransport)
227+
assert transport.sse_read_timeout is None
228+
assert getattr(transport, "_hud_client_timeout", None) == 300
229+
230+
def test_connect_mcp_sse_transport_keeps_sse_timeout(self) -> None:
231+
"""SSE transports should continue to receive sse_read_timeout directly."""
232+
from fastmcp.client.transports import SSETransport
233+
234+
from hud.environment.connectors.mcp_config import MCPConfigConnectorMixin
235+
from hud.settings import Settings
236+
237+
class TestEnv(MCPConfigConnectorMixin):
238+
def __init__(self) -> None:
239+
self._connections: dict[str, Connector] = {}
240+
241+
env = TestEnv()
242+
with patch("hud.settings.settings", spec=Settings) as mock_settings:
243+
mock_settings.client_timeout = 300
244+
env.connect_mcp({"browser": {"url": "https://mcp.hud.ai/browser", "transport": "sse"}})
245+
246+
transport = env._connections["browser"]._transport
247+
assert isinstance(transport, SSETransport)
248+
assert transport.sse_read_timeout is not None
249+
assert transport.sse_read_timeout.total_seconds() == 300

0 commit comments

Comments
 (0)