From 0b6d6f4f49a0b7285b65cf734d43b3adaf3349fe Mon Sep 17 00:00:00 2001 From: gonzaloandresoto Date: Fri, 24 Jul 2026 05:29:05 +0000 Subject: [PATCH 1/3] resolve server_routes_file pointer in gumcp transport config server_routes now ship as a sandbox file instead of inline in the GUMCP_CONFIG env var (oversized env blocks broke every sandbox exec with E2BIG for orgs with many gumstack servers). the transport loads the file into the client config and keys the cached client on the file's mtime so a refreshed per-call file rebuilds the session. Co-authored-by: Cursor --- src/gumloop/_gumcp_transport.py | 39 ++++++++++++++--- tests/sdk/test_gumcp_transport.py | 72 +++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/gumloop/_gumcp_transport.py b/src/gumloop/_gumcp_transport.py index 5efc8b5..77e5bbe 100644 --- a/src/gumloop/_gumcp_transport.py +++ b/src/gumloop/_gumcp_transport.py @@ -18,6 +18,7 @@ import threading from collections.abc import Sequence from concurrent.futures import TimeoutError as FutureTimeoutError +from pathlib import Path from typing import Any from gumloop.errors import GumloopError @@ -54,7 +55,34 @@ def _load_config() -> dict[str, Any]: parsed = json.loads(raw) except (json.JSONDecodeError, TypeError): return {} - return parsed if isinstance(parsed, dict) else {} + if not isinstance(parsed, dict): + return {} + # server_routes ship as a sandbox file (too large for the exec env); + # resolve the pointer so the client sees inline routes. + routes_file = parsed.get("server_routes_file") + if routes_file and not parsed.get("server_routes"): + try: + parsed["server_routes"] = json.loads(Path(routes_file).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + pass # non-routed servers still work; routed calls surface errors + return parsed + + +def _routes_file_mtime() -> float: + """mtime of the routes file referenced by GUMCP_CONFIG, 0.0 when absent.""" + raw = os.environ.get("GUMCP_CONFIG") or "" + if not raw.startswith("{"): + return 0.0 + try: + routes_file = json.loads(raw).get("server_routes_file") + except (json.JSONDecodeError, TypeError): + return 0.0 + if not routes_file: + return 0.0 + try: + return Path(routes_file).stat().st_mtime + except OSError: + return 0.0 def _normalize_calls( @@ -216,17 +244,18 @@ class GumcpTransport: def __init__(self) -> None: self._client: Any | None = None - self._fingerprint: tuple[str, str, str] | None = None + self._fingerprint: tuple[str, str, str, float] | None = None self._loop: asyncio.AbstractEventLoop | None = None self._loop_thread: threading.Thread | None = None self._loop_lock = threading.Lock() self._session_lock = asyncio.Lock() - def _current_fingerprint(self) -> tuple[str, str, str]: + def _current_fingerprint(self) -> tuple[str, str, str, float]: token = os.environ.get("GUMCP_ACCESS_TOKEN") or "" base_url = (os.environ.get("GUMCP_BASE_URL") or "").rstrip("/") config_raw = os.environ.get("GUMCP_CONFIG") or "" - return (token, base_url, config_raw) + # routes-file mtime: a refreshed per-call file must rebuild the client + return (token, base_url, config_raw, _routes_file_mtime()) async def _close_client_unlocked(self) -> None: client = self._client @@ -245,7 +274,7 @@ async def _close_client(self) -> None: async def _ensure_client(self) -> Any: fingerprint = self._current_fingerprint() - token, base_url, _config_raw = fingerprint + token, base_url, _config_raw, _routes_mtime = fingerprint if not token or not base_url: raise GumloopError("GUMCP_ACCESS_TOKEN and GUMCP_BASE_URL are required for direct MCP transport") diff --git a/tests/sdk/test_gumcp_transport.py b/tests/sdk/test_gumcp_transport.py index d3d88bb..d6079b9 100644 --- a/tests/sdk/test_gumcp_transport.py +++ b/tests/sdk/test_gumcp_transport.py @@ -1,7 +1,10 @@ from __future__ import annotations import asyncio +import json +import os import sys +from pathlib import Path from typing import Any from unittest.mock import AsyncMock from unittest.mock import MagicMock @@ -315,6 +318,75 @@ def _factory(**kwargs: Any) -> Any: assert captured["config"] == {"allowed_servers": ["gmail"], "server_routes": {}} +def test_factory_config_resolves_server_routes_file( + gumcp_env: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """server_routes ship as a file (too large for the exec env); the client must see them inline.""" + routes = { + "gs-1": {"base_url": "https://gs-1/mcp", "headers": {"Authorization": "Bearer t"}, "server_type": "gumstack"}, + "srv-1": {"alias_of": "gs-1"}, + } + routes_file = tmp_path / ".gumcp_server_routes.json" + routes_file.write_text(json.dumps(routes)) + monkeypatch.setenv( + "GUMCP_CONFIG", + json.dumps({"allowed_servers": ["gs-1"], "server_routes_file": str(routes_file)}), + ) + captured: dict[str, Any] = {} + + def _factory(**kwargs: Any) -> Any: + captured.update(kwargs) + mock_client = MagicMock() + mock_client.call_tool = AsyncMock(return_value=["ok"]) + mock_client.close = AsyncMock() + return mock_client + + with patch("gumloop._gumcp_transport._import_async_client", return_value=_factory): + client = Gumloop(access_token="http-token") + client.mcp.execute("gs-1", "some_tool", {}) + client.close() + + assert captured["config"]["server_routes"] == routes + + +@pytest.mark.parametrize("file_state", ["missing", "malformed"]) +def test_load_config_tolerates_bad_routes_file( + gumcp_env: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, file_state: str +) -> None: + from gumloop._gumcp_transport import _load_config + + routes_file = tmp_path / ".gumcp_server_routes.json" + if file_state == "malformed": + routes_file.write_text("not-json{{{") + monkeypatch.setenv( + "GUMCP_CONFIG", + json.dumps({"allowed_servers": ["gs-1"], "server_routes_file": str(routes_file)}), + ) + + config = _load_config() + + assert "server_routes" not in config + assert config["allowed_servers"] == ["gs-1"] + + +def test_fingerprint_changes_when_routes_file_changes( + gumcp_env: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A refreshed per-call routes file must rebuild the cached client.""" + transport = GumcpTransport() + routes_file = tmp_path / ".gumcp_server_routes.json" + routes_file.write_text(json.dumps({"gs-1": {"base_url": "u", "headers": {}, "server_type": "gumstack"}})) + monkeypatch.setenv("GUMCP_CONFIG", json.dumps({"server_routes_file": str(routes_file)})) + + first = transport._current_fingerprint() + mtime = routes_file.stat().st_mtime + os.utime(routes_file, (mtime + 10, mtime + 10)) + second = transport._current_fingerprint() + + assert first != second + assert first[:3] == second[:3] + + def test_sync_execute_inside_running_loop(gumcp_env: None) -> None: """Chat kernels call sync execute from within a running loop (Jupyter): the calling thread blocks while the transport thread does the work — From 6fc621ee76565f259f76afcf1128d94f63d69bea Mon Sep 17 00:00:00 2001 From: gonzaloandresoto Date: Fri, 24 Jul 2026 19:59:45 +0000 Subject: [PATCH 2/3] log instead of swallowing an unreadable server_routes_file An unreadable or corrupt routes file left routed servers silently missing from the config. Keep the degrade, since a client with only gateway servers has no routes file at all, but say so at warning level. Drops two comments that restated the code. Co-authored-by: Cursor --- src/gumloop/_gumcp_transport.py | 8 ++++---- tests/sdk/test_gumcp_transport.py | 13 ++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/gumloop/_gumcp_transport.py b/src/gumloop/_gumcp_transport.py index 77e5bbe..655e8f1 100644 --- a/src/gumloop/_gumcp_transport.py +++ b/src/gumloop/_gumcp_transport.py @@ -13,6 +13,7 @@ import asyncio import json +import logging import os import re import threading @@ -26,6 +27,8 @@ from gumloop.types import McpToolCallRequest from gumloop.types import McpToolCallResult +logger = logging.getLogger(__name__) + _MAX_BATCH = 5 _HTTP_STATUS_RE = re.compile(r"HTTP\s+(\d{3})") _LIVENESS_POLL_SECONDS = 1.0 @@ -57,14 +60,12 @@ def _load_config() -> dict[str, Any]: return {} if not isinstance(parsed, dict): return {} - # server_routes ship as a sandbox file (too large for the exec env); - # resolve the pointer so the client sees inline routes. routes_file = parsed.get("server_routes_file") if routes_file and not parsed.get("server_routes"): try: parsed["server_routes"] = json.loads(Path(routes_file).read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): - pass # non-routed servers still work; routed calls surface errors + logger.warning("unreadable server_routes_file %s; routed servers unavailable", routes_file) return parsed @@ -254,7 +255,6 @@ def _current_fingerprint(self) -> tuple[str, str, str, float]: token = os.environ.get("GUMCP_ACCESS_TOKEN") or "" base_url = (os.environ.get("GUMCP_BASE_URL") or "").rstrip("/") config_raw = os.environ.get("GUMCP_CONFIG") or "" - # routes-file mtime: a refreshed per-call file must rebuild the client return (token, base_url, config_raw, _routes_file_mtime()) async def _close_client_unlocked(self) -> None: diff --git a/tests/sdk/test_gumcp_transport.py b/tests/sdk/test_gumcp_transport.py index d6079b9..2b7d1fe 100644 --- a/tests/sdk/test_gumcp_transport.py +++ b/tests/sdk/test_gumcp_transport.py @@ -2,6 +2,7 @@ import asyncio import json +import logging import os import sys from pathlib import Path @@ -350,8 +351,12 @@ def _factory(**kwargs: Any) -> Any: @pytest.mark.parametrize("file_state", ["missing", "malformed"]) -def test_load_config_tolerates_bad_routes_file( - gumcp_env: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, file_state: str +def test_load_config_warns_and_degrades_on_bad_routes_file( + gumcp_env: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + file_state: str, ) -> None: from gumloop._gumcp_transport import _load_config @@ -363,10 +368,12 @@ def test_load_config_tolerates_bad_routes_file( json.dumps({"allowed_servers": ["gs-1"], "server_routes_file": str(routes_file)}), ) - config = _load_config() + with caplog.at_level(logging.WARNING, logger="gumloop._gumcp_transport"): + config = _load_config() assert "server_routes" not in config assert config["allowed_servers"] == ["gs-1"] + assert str(routes_file) in caplog.text def test_fingerprint_changes_when_routes_file_changes( From 4e3cf861bd490dd9992abc706a47080f2193385f Mon Sep 17 00:00:00 2001 From: gonzaloandresoto Date: Fri, 24 Jul 2026 19:59:45 +0000 Subject: [PATCH 3/3] bump version to 0.4.6 Co-authored-by: Cursor --- 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 9d232fd..a1246f1 100644 --- a/src/gumloop/_version.py +++ b/src/gumloop/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "0.4.5" +__version__ = "0.4.6"