diff --git a/src/gumloop/_gumcp_transport.py b/src/gumloop/_gumcp_transport.py index 5efc8b5..655e8f1 100644 --- a/src/gumloop/_gumcp_transport.py +++ b/src/gumloop/_gumcp_transport.py @@ -13,11 +13,13 @@ import asyncio import json +import logging import os import re 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 @@ -25,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 @@ -54,7 +58,32 @@ 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 {} + 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): + logger.warning("unreadable server_routes_file %s; routed servers unavailable", routes_file) + 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 +245,17 @@ 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) + 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/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" diff --git a/tests/sdk/test_gumcp_transport.py b/tests/sdk/test_gumcp_transport.py index d3d88bb..2b7d1fe 100644 --- a/tests/sdk/test_gumcp_transport.py +++ b/tests/sdk/test_gumcp_transport.py @@ -1,7 +1,11 @@ from __future__ import annotations import asyncio +import json +import logging +import os import sys +from pathlib import Path from typing import Any from unittest.mock import AsyncMock from unittest.mock import MagicMock @@ -315,6 +319,81 @@ 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_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 + + 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)}), + ) + + 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( + 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 —