Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions src/gumloop/_gumcp_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,22 @@

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
from gumloop.types import McpExecuteResponse
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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion src/gumloop/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from __future__ import annotations

__version__ = "0.4.5"
__version__ = "0.4.6"
79 changes: 79 additions & 0 deletions tests/sdk/test_gumcp_transport.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 —
Expand Down