From a30350f9b3cc90b824ba785dad5e90630579ff26 Mon Sep 17 00:00:00 2001 From: rbehal Date: Sat, 24 Oct 2026 02:17:40 +0000 Subject: [PATCH 1/3] [Fix] A sandbox MCP call with no answer now fails after 330 s instead of hanging its thread In a sandbox, client.mcp.execute goes through one shared gumcp_client session on a background loop, and the calling thread waited on it with no deadline. When a response was lost (a load balancer 502 on one POST tore down the session in gumstack-mcp 0.1.38), every thread waiting on that session blocked until the agent's own shell timeout killed the script, with no error. Each direct-transport call now has a 330 s deadline, above the backend's 300 s per-tool-call limit, so it only ends a call that will never be answered. It returns the same tool_execution_failed / "Tool call timed out after Ns." result as the HTTP path and logs a warning. The deadline cancels the hung call, which makes gumcp_client drop that server's session so the next call reconnects. The deadline is asyncio.wait_for rather than an anyio scope: the first call connects lazily and leaves the session's anyio scopes open in its task, so an enclosing anyio scope could not exit cleanly. A broken session's "cancel scope" CancelledError is converted inside the wait_for task so the existing mcp_server_connection_failed mapping still sees its message on Python 3.10. Co-authored-by: Cursor --- src/gumloop/_gumcp_transport.py | 38 +++++++++++-- tests/sdk/test_gumcp_transport.py | 90 +++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/gumloop/_gumcp_transport.py b/src/gumloop/_gumcp_transport.py index f3c2fc78..a13b9783 100644 --- a/src/gumloop/_gumcp_transport.py +++ b/src/gumloop/_gumcp_transport.py @@ -32,6 +32,9 @@ _MAX_BATCH = 5 _HTTP_STATUS_RE = re.compile(r"HTTP\s+(\d{3})") _LIVENESS_POLL_SECONDS = 1.0 +# Above the backend's 300 s per-tool-call limit, so it only ends a call whose answer +# was lost; without it a lost response blocks the calling thread forever. +_CALL_TIMEOUT_SECONDS = 330.0 def gumcp_env_ready() -> bool: @@ -252,6 +255,23 @@ def _map_exception(exc: BaseException, *, ref: str, server_id: str, tool_name: s ) +class _CancelScopeError(Exception): + """anyio's "cancel scope" CancelledError from a broken session, as an ordinary error.""" + + +async def _call_tool(client: Any, name: str, arguments: dict[str, Any]) -> Any: + # Runs as the asyncio.wait_for task on 3.10/3.11, whose result() drops a + # CancelledError's message, so the "cancel scope" case is converted in here. + # The deadline cannot be an anyio scope instead: the first call connects + # lazily and leaves the session's anyio scopes open in its own task. + try: + return await client.call_tool(name, arguments) + except asyncio.CancelledError as exc: + if "cancel scope" in str(exc): + raise _CancelScopeError(str(exc)) from exc + raise + + class GumcpTransport: """Owns one multi-server ``AsyncClient`` keyed by the current GUMCP env fingerprint.""" @@ -325,9 +345,9 @@ async def call_one( result_ref = ref or fallback_ref try: client = await self._ensure_client() - content = await client.call_tool( - f"{server_id}__{tool_name}", - arguments or {}, + content = await asyncio.wait_for( + _call_tool(client, f"{server_id}__{tool_name}", arguments or {}), + timeout=_CALL_TIMEOUT_SECONDS, ) return McpToolCallResult( ref=result_ref, @@ -338,6 +358,18 @@ async def call_one( ) except GumloopError: raise + except asyncio.TimeoutError: + logger.warning("MCP tool call %s__%s got no answer within %gs", server_id, tool_name, _CALL_TIMEOUT_SECONDS) + return _error_result( + ref=result_ref, + server_id=server_id, + tool_name=tool_name, + status="error", + code="tool_execution_failed", + message=f"Tool call timed out after {_CALL_TIMEOUT_SECONDS:g}s.", + error_type="api_error", + details={"server_id": server_id, "tool_name": tool_name}, + ) except asyncio.CancelledError as exc: # Real cancellation propagates; only the anyio cancel-scope failure maps. if "cancel scope" not in str(exc): diff --git a/tests/sdk/test_gumcp_transport.py b/tests/sdk/test_gumcp_transport.py index 470179ce..bfbc706c 100644 --- a/tests/sdk/test_gumcp_transport.py +++ b/tests/sdk/test_gumcp_transport.py @@ -5,12 +5,14 @@ import logging import os import sys +from contextlib import AsyncExitStack from pathlib import Path from typing import Any from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch +import anyio import httpx import pytest import respx @@ -482,6 +484,94 @@ async def close(self) -> None: assert len(set(seen_loops)) == 1 # everything ran on the transport loop +class _HangingClient: + """Answers every tool except ``gmail__hang``, which never gets a response.""" + + hung_call_cancelled = False + + def __init__(self, **_kwargs: Any) -> None: + pass + + async def call_tool(self, tool: str, _args: dict[str, Any]) -> list[str]: + if tool == "gmail__hang": + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + _HangingClient.hung_call_cancelled = True + raise + await asyncio.sleep(0.01) + return [tool] + + async def close(self) -> None: + pass + + +def test_a_call_that_never_answers_times_out_and_the_next_call_succeeds( + gumcp_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + import gumloop._gumcp_transport as transport_mod + + monkeypatch.setattr(transport_mod, "_CALL_TIMEOUT_SECONDS", 0.1) + monkeypatch.setattr(_HangingClient, "hung_call_cancelled", False) + + with patch("gumloop._gumcp_transport._import_async_client", return_value=_HangingClient): + client = Gumloop(access_token="http-token") + hung = client.mcp.execute("gmail", "hang", {}).results[0] + after = client.mcp.execute("gmail", "read_emails", {}).results[0] + client.close() + + assert hung.status == "error" + assert hung.error is not None + assert hung.error["code"] == "tool_execution_failed" + assert "timed out after 0.1s" in hung.error["message"] + assert _HangingClient.hung_call_cancelled + assert after.status == "success" + + +class _LazyConnectClient: + """Opens a long-lived anyio task group on its first call, as gumcp_client's lazy connect does.""" + + def __init__(self, **_kwargs: Any) -> None: + self._connection: AsyncExitStack | None = None + + async def call_tool(self, tool: str, _args: dict[str, Any]) -> list[str]: + if self._connection is None: + self._connection = AsyncExitStack() + await self._connection.enter_async_context(anyio.create_task_group()) + return [tool] + + async def close(self) -> None: + pass + + +def test_the_call_that_opens_the_connection_succeeds(gumcp_env: None) -> None: + with patch("gumloop._gumcp_transport._import_async_client", return_value=_LazyConnectClient): + client = Gumloop(access_token="http-token") + results = [client.mcp.execute("gmail", f"tool_{i}", {}).results[0] for i in range(2)] + client.close() + + assert [r.status for r in results] == ["success", "success"] + + +def test_threaded_fanout_survives_one_call_that_never_answers(gumcp_env: None, monkeypatch: pytest.MonkeyPatch) -> None: + from concurrent.futures import ThreadPoolExecutor + + import gumloop._gumcp_transport as transport_mod + + monkeypatch.setattr(transport_mod, "_CALL_TIMEOUT_SECONDS", 0.2) + tools = [f"tool_{i}" for i in range(23)] + ["hang"] + + with patch("gumloop._gumcp_transport._import_async_client", return_value=_HangingClient): + client = Gumloop(access_token="http-token") + with ThreadPoolExecutor(max_workers=6) as ex: + futures = {tool: ex.submit(client.mcp.execute, "gmail", tool, {}) for tool in tools} + results = {tool: future.result(timeout=10).results[0] for tool, future in futures.items()} + client.close() + + assert results.pop("hang").status == "error" + assert all(r.status == "success" for r in results.values()) + + def test_close_on_unused_transport_is_a_noop(gumcp_env: None) -> None: transport = GumcpTransport() transport.close() From 69ec6ac54fed46853b9beccd90124ff00a93f9ac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 23:48:40 +0000 Subject: [PATCH 2/3] [FTRY-1487] Update gumloop-py to 0.5.10 so the deadline fix can be tagged after the released 0.5.9 The release workflow requires the tag to match _version.py, and v0.5.9 is already on PyPI. Co-authored-by: Rahul Behal --- src/gumloop/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gumloop/_version.py b/src/gumloop/_version.py index 3cf9ae04..ba2fca43 100644 --- a/src/gumloop/_version.py +++ b/src/gumloop/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "0.5.9" +__version__ = "0.5.10" From 1c73ef966af38d0db3f6fee70f2fbcd710982ef2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 23:48:41 +0000 Subject: [PATCH 3/3] [FTRY-1487] Update the call-deadline comments to state only the constraint each one guards The 330 s value must stay above the backend's limit and the in-task conversion exists for a Python 3.10/3.11 quirk; the test-double docstrings repeated their code and are removed. Co-authored-by: Rahul Behal --- src/gumloop/_gumcp_transport.py | 8 ++------ tests/sdk/test_gumcp_transport.py | 4 ---- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/gumloop/_gumcp_transport.py b/src/gumloop/_gumcp_transport.py index a13b9783..b6d1a5f5 100644 --- a/src/gumloop/_gumcp_transport.py +++ b/src/gumloop/_gumcp_transport.py @@ -32,8 +32,7 @@ _MAX_BATCH = 5 _HTTP_STATUS_RE = re.compile(r"HTTP\s+(\d{3})") _LIVENESS_POLL_SECONDS = 1.0 -# Above the backend's 300 s per-tool-call limit, so it only ends a call whose answer -# was lost; without it a lost response blocks the calling thread forever. +# Above the backend's 300 s per-tool-call limit, so it only ends a call whose answer was lost. _CALL_TIMEOUT_SECONDS = 330.0 @@ -260,10 +259,7 @@ class _CancelScopeError(Exception): async def _call_tool(client: Any, name: str, arguments: dict[str, Any]) -> Any: - # Runs as the asyncio.wait_for task on 3.10/3.11, whose result() drops a - # CancelledError's message, so the "cancel scope" case is converted in here. - # The deadline cannot be an anyio scope instead: the first call connects - # lazily and leaves the session's anyio scopes open in its own task. + # On 3.10/3.11 wait_for drops a CancelledError's message, so "cancel scope" is converted in here. try: return await client.call_tool(name, arguments) except asyncio.CancelledError as exc: diff --git a/tests/sdk/test_gumcp_transport.py b/tests/sdk/test_gumcp_transport.py index bfbc706c..f556dbb9 100644 --- a/tests/sdk/test_gumcp_transport.py +++ b/tests/sdk/test_gumcp_transport.py @@ -485,8 +485,6 @@ async def close(self) -> None: class _HangingClient: - """Answers every tool except ``gmail__hang``, which never gets a response.""" - hung_call_cancelled = False def __init__(self, **_kwargs: Any) -> None: @@ -529,8 +527,6 @@ def test_a_call_that_never_answers_times_out_and_the_next_call_succeeds( class _LazyConnectClient: - """Opens a long-lived anyio task group on its first call, as gumcp_client's lazy connect does.""" - def __init__(self, **_kwargs: Any) -> None: self._connection: AsyncExitStack | None = None