Skip to content

Commit bea4614

Browse files
authored
Merge pull request #46 from callingmedic911/fix/mcp-tool-call-timeout
fix: honor tool_call_timeout so slow MCP tool calls stop hanging
2 parents bc6fba1 + f8cd906 commit bea4614

3 files changed

Lines changed: 116 additions & 10 deletions

File tree

src/nooa/mcp/client.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
from mcp.client.sse import sse_client
1818
from mcp.client.streamable_http import streamable_http_client
1919

20+
# Establishing the connection is not a tool call, so it keeps its own short budget.
21+
# Matches the connect timeout the MCP SDK's own SSE transport defaults to.
22+
CONNECT_TIMEOUT_SECONDS = 5.0
23+
2024

2125
class MCPBaseClient(ABC):
2226
"""Base client for creating an MCP transport session and connecting to an MCP server.
@@ -120,7 +124,7 @@ async def connect_to_server(self):
120124
url=self._url,
121125
headers=self._headers if self._headers else None,
122126
) as (read, write),
123-
ClientSession(read, write) as session,
127+
ClientSession(read, write, read_timeout_seconds=self._tool_call_timeout) as session,
124128
):
125129
await session.initialize()
126130
yield session
@@ -197,7 +201,7 @@ async def connect_to_server(self):
197201
)
198202
async with (
199203
stdio_client(server_params) as (read, write),
200-
ClientSession(read, write) as session,
204+
ClientSession(read, write, read_timeout_seconds=self._tool_call_timeout) as session,
201205
):
202206
await session.initialize()
203207
yield session
@@ -273,9 +277,19 @@ async def connect_to_server(self):
273277
httpx.HTTPStatusError: If server returns HTTP error (e.g., 401 Unauthorized, 500)
274278
RuntimeError: If session initialization fails (MCP protocol error)
275279
"""
276-
# Create httpx client with custom headers
277-
# streamable_http_client expects a pre-configured httpx.AsyncClient for headers
278-
http_client = httpx.AsyncClient(headers=self._headers if self._headers else None)
280+
# Create httpx client with custom headers.
281+
# streamable_http_client expects a pre-configured httpx.AsyncClient, so this
282+
# client's timeouts are the only ones that apply: the transport has no timeout
283+
# arguments of its own to fall back on. Reading the response has to be allowed
284+
# to take as long as a tool call may take, or a slow tool's reply arrives on a
285+
# stream httpx already abandoned and the caller waits forever.
286+
http_client = httpx.AsyncClient(
287+
headers=self._headers if self._headers else None,
288+
timeout=httpx.Timeout(
289+
self._tool_call_timeout.total_seconds(),
290+
connect=CONNECT_TIMEOUT_SECONDS,
291+
),
292+
)
279293

280294
try:
281295
async with (
@@ -288,7 +302,9 @@ async def connect_to_server(self):
288302
):
289303
# Store the session ID callback for later retrieval
290304
self._get_mcp_session_id = get_session_id
291-
async with ClientSession(read, write) as session:
305+
async with ClientSession(
306+
read, write, read_timeout_seconds=self._tool_call_timeout
307+
) as session:
292308
await session.initialize()
293309
yield session
294310
finally:
@@ -303,6 +319,7 @@ def create_mcp_client(
303319
args: list[str] | None = None,
304320
env: dict[str, str] | None = None,
305321
headers: dict[str, str] | None = None,
322+
tool_call_timeout: timedelta = timedelta(seconds=60),
306323
) -> MCPBaseClient:
307324
"""Create an MCP client based on the transport type and configuration.
308325
@@ -313,6 +330,7 @@ def create_mcp_client(
313330
args: Command arguments (optional, for stdio transport)
314331
env: Environment variables for the server process (optional, for stdio transport)
315332
headers: Optional custom HTTP headers to include in requests (for HTTP transports)
333+
tool_call_timeout: How long one tool call may take before it fails
316334
317335
Returns:
318336
An MCPBaseClient instance configured for the specified transport
@@ -328,15 +346,19 @@ def create_mcp_client(
328346
case "stdio":
329347
if command is None:
330348
raise ValueError("command must be provided for stdio transport")
331-
return MCPStdioClient(command=command, args=args, env=env)
349+
return MCPStdioClient(
350+
command=command, args=args, env=env, tool_call_timeout=tool_call_timeout
351+
)
332352
case "sse":
333353
if url is None:
334354
raise ValueError("url must be provided for sse transport")
335-
return MCPSSEClient(url=url, headers=headers)
355+
return MCPSSEClient(url=url, headers=headers, tool_call_timeout=tool_call_timeout)
336356
case "streamable-http":
337357
if url is None:
338358
raise ValueError("url must be provided for streamable-http transport")
339-
return MCPStreamableHTTPClient(url=url, headers=headers)
359+
return MCPStreamableHTTPClient(
360+
url=url, headers=headers, tool_call_timeout=tool_call_timeout
361+
)
340362
case _:
341363
raise ValueError(
342364
f"Unsupported transport type: {transport}. Use 'stdio', 'sse', or 'streamable-http'"

src/nooa/mcp/tool.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import types
1818
from collections.abc import Awaitable, Callable, Sequence
1919
from dataclasses import dataclass, field
20+
from datetime import timedelta
2021
from pathlib import Path
2122
from typing import Any, Literal
2223

@@ -767,6 +768,7 @@ def create_from_server(
767768
oauth_timeout: float | None = None,
768769
mcp_file: Path | None = None,
769770
servers: dict[str, dict[str, Any]] | None = None,
771+
tool_call_timeout: timedelta = timedelta(seconds=60),
770772
) -> MCPTool:
771773
"""Create a per-server tool instance; connects to the MCP server.
772774
@@ -789,6 +791,8 @@ def create_from_server(
789791
oauth_browser_open: Async hook to open the auth URL in a reachable browser (host handoff).
790792
mcp_file: Path to .mcp.json file (default: .mcp.json in cwd)
791793
servers: Optional inline server config from the TUI config.toml.
794+
tool_call_timeout: How long one tool call may take before it fails.
795+
Raise it for servers whose tools wrap slow work such as an LLM call.
792796
793797
Returns:
794798
An MCPTool instance (dynamically generated class with methods for each tool).
@@ -845,6 +849,7 @@ def _run_sync(coro):
845849
args=args,
846850
env=env,
847851
headers=headers,
852+
tool_call_timeout=tool_call_timeout,
848853
)
849854

850855
# Connect and list tools (with OAuth retry if needed)
@@ -890,6 +895,7 @@ async def _connect_and_list():
890895
args=args,
891896
env=env,
892897
headers=headers,
898+
tool_call_timeout=tool_call_timeout,
893899
)
894900

895901
async def _connect_and_list_retry():

tests/test_mcp/test_client.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,83 @@ async def test_sse_headers_passed_to_transport(
371371
)
372372

373373

374+
@pytest.mark.asyncio
375+
async def test_streamable_http_applies_tool_call_timeout(
376+
streamable_http_client: MCPStreamableHTTPClient,
377+
mock_client_session: AsyncMock,
378+
):
379+
"""streamable-http gives httpx and the session the caller's tool_call_timeout.
380+
381+
The transport has no timeout arguments of its own and uses whatever client it is
382+
handed, so an httpx client built without one caps every tool call at httpx's 5s
383+
default no matter what tool_call_timeout says.
384+
"""
385+
with (
386+
patch("nooa.mcp.client.httpx.AsyncClient") as mock_async_client,
387+
patch("nooa.mcp.client.streamable_http_client") as mock_http,
388+
patch("nooa.mcp.client.ClientSession") as mock_session_class,
389+
):
390+
mock_http.return_value.__aenter__.return_value = (MagicMock(), MagicMock(), MagicMock())
391+
mock_session_class.return_value.__aenter__.return_value = mock_client_session
392+
393+
async with streamable_http_client.connect_to_server():
394+
pass
395+
396+
timeout = mock_async_client.call_args.kwargs["timeout"]
397+
assert timeout.read == 90
398+
assert timeout.write == 90
399+
# Opening the connection is not a tool call and keeps its own short budget.
400+
assert timeout.connect == 5.0
401+
assert mock_session_class.call_args.kwargs["read_timeout_seconds"] == timedelta(seconds=90)
402+
403+
404+
@pytest.mark.asyncio
405+
@pytest.mark.parametrize(
406+
"client_fixture, transport_patch, expected_timeout",
407+
[
408+
("sse_client", "nooa.mcp.client.sse_client", timedelta(seconds=45)),
409+
("stdio_client", "nooa.mcp.client.stdio_client", timedelta(seconds=30)),
410+
],
411+
)
412+
async def test_session_enforces_tool_call_timeout(
413+
client_fixture: str,
414+
transport_patch: str,
415+
expected_timeout: timedelta,
416+
request: pytest.FixtureRequest,
417+
mock_mcp_transport: tuple[MagicMock, MagicMock],
418+
mock_client_session: AsyncMock,
419+
):
420+
"""Every transport hands tool_call_timeout to the session that enforces it."""
421+
client: MCPBaseClient = request.getfixturevalue(client_fixture)
422+
423+
with (
424+
patch(transport_patch) as mock_transport,
425+
patch("nooa.mcp.client.ClientSession") as mock_session_class,
426+
):
427+
mock_transport.return_value.__aenter__.return_value = mock_mcp_transport
428+
mock_session_class.return_value.__aenter__.return_value = mock_client_session
429+
430+
async with client.connect_to_server():
431+
pass
432+
433+
assert mock_session_class.call_args.kwargs["read_timeout_seconds"] == expected_timeout
434+
435+
436+
@pytest.mark.parametrize(
437+
"kwargs",
438+
[
439+
{"transport": "stdio", "command": "python"},
440+
{"transport": "sse", "url": "https://example.test/sse"},
441+
{"transport": "streamable-http", "url": "https://example.test/mcp"},
442+
],
443+
)
444+
def test_create_mcp_client_forwards_tool_call_timeout(kwargs: dict[str, str]):
445+
"""create_mcp_client is the documented entry point and must pass the timeout on."""
446+
client = create_mcp_client(tool_call_timeout=timedelta(seconds=7), **kwargs)
447+
448+
assert client.tool_call_timeout == timedelta(seconds=7)
449+
450+
374451
@pytest.mark.asyncio
375452
async def test_streamable_http_connect_context_manager(
376453
streamable_http_client: MCPStreamableHTTPClient,
@@ -439,7 +516,8 @@ async def test_streamable_http_headers_passed_to_httpx_client(
439516
pass
440517

441518
# Verify httpx.AsyncClient was created with expected headers
442-
mock_httpx_client.assert_called_once_with(headers=expected_headers)
519+
mock_httpx_client.assert_called_once()
520+
assert mock_httpx_client.call_args.kwargs["headers"] == expected_headers
443521

444522

445523
def test_dynamic_method_supports_json_container_defaults():

0 commit comments

Comments
 (0)