Skip to content
Closed
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
108 changes: 108 additions & 0 deletions tests/test_mcp_bridge_runtime_port.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Regression test: the bridge must find the API on a non-default port.

`the-lab . --port 9009` is a documented flag, but `the-lab init` writes a
.mcp.json with no THE_LAB_API_URL, so a client that launches the bridge from
that file (Claude Code opened in the project, an IDE, any MCP host) used to get
the hardcoded port 8000. The bridge then starts anyway and advertises only its
two static tools, so the agent runs blind with nothing but a stderr warning.

The server already records where it bound in .the_lab/sandbox/runtime.json.
This test puts that file next to a stub spec server on a random port, runs the
bridge from the project directory with no environment override, and asserts the
spec-derived tools are there.
"""
import http.server
import json
import os
import subprocess
import sys
import tempfile
import threading
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
BRIDGE = ROOT / "the_lab" / "agent_skills" / "skills" / "lab_api_mcp.py"
BRIDGE_PYTHON = os.environ.get("LAB_MCP_PYTHON") or sys.executable
SPEC_SCRIPT = "import json; from the_lab.app import app; print(json.dumps(app.openapi()))"


def _real_openapi_spec():
# Importing the app initialises a project in the cwd, so do it in a
# throwaway directory and a separate process.
with tempfile.TemporaryDirectory() as tmp:
out = subprocess.run(
[sys.executable, "-c", SPEC_SCRIPT],
cwd=tmp, capture_output=True, text=True, timeout=120,
env=dict(os.environ, PYTHONPATH=str(ROOT)),
)
if out.returncode != 0:
raise RuntimeError("could not build OpenAPI spec:\n" + out.stderr)
return out.stdout.strip().splitlines()[-1].encode()


class BridgeReadsRuntimePort(unittest.TestCase):
@classmethod
def setUpClass(cls):
spec = _real_openapi_spec()

class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
found = self.path == "/openapi.json"
body = spec if found else b"{}"
self.send_response(200 if found else 404)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def log_message(self, *args):
pass

cls.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=cls.server.serve_forever, daemon=True).start()

@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()

def _tools_from_bridge(self, cwd: Path) -> set:
msgs = [
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
"clientInfo": {"name": "test", "version": "0"}}},
{"jsonrpc": "2.0", "method": "notifications/initialized"},
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
]
env = {k: v for k, v in os.environ.items() if k != "THE_LAB_API_URL"}
proc = subprocess.run(
[BRIDGE_PYTHON, str(BRIDGE)],
input="".join(json.dumps(m) + os.linesep for m in msgs),
capture_output=True, text=True, timeout=60, cwd=str(cwd), env=env,
)
self.assertEqual(proc.returncode, 0, proc.stderr)
replies = {r.get("id"): r for r in map(json.loads, proc.stdout.splitlines())}
return {t["name"] for t in replies.get(2, {}).get("result", {}).get("tools", [])}

def test_port_comes_from_runtime_json(self):
port = self.server.server_address[1]
with tempfile.TemporaryDirectory() as tmp:
project = Path(tmp)
runtime = project / ".the_lab" / "sandbox"
runtime.mkdir(parents=True)
(runtime / "runtime.json").write_text(json.dumps(
{"api_scheme": "http", "api_host": "127.0.0.1", "api_port": port}
), encoding="utf-8")

# Launched from a subdirectory: the bridge walks up to the project.
nested = project / "benchmark"
nested.mkdir()
tools = self._tools_from_bridge(nested)

for name in ("orient", "create_idea", "list_ideas", "get_instructions"):
self.assertIn(name, tools, f"bridge did not reach the API on port {port}")


if __name__ == "__main__":
unittest.main()
35 changes: 34 additions & 1 deletion the_lab/agent_skills/skills/lab_api_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import hashlib as _hashlib
import json
import os
import pathlib
import socket as _socket
import ssl as _ssl
import struct as _struct
Expand All @@ -18,7 +19,39 @@

# ── Configuration ─────────────────────────────────────────────────────────────

API_BASE = os.environ.get("THE_LAB_API_URL", "http://localhost:8000/api/v1")
def _api_base_from_runtime():
"""Read the live API endpoint the server recorded in the project.

`the-lab .` writes .the_lab/sandbox/runtime.json with the scheme/host/port
it actually bound. Walking up from the cwd finds it whenever the bridge is
started inside the project (that is how .mcp.json launches it), so a
non-default `--port` no longer leaves the bridge pointed at port 8000.
"""
here = pathlib.Path.cwd().resolve()
for d in (here, *here.parents):
candidate = d / ".the_lab" / "sandbox" / "runtime.json"
if not candidate.is_file():
continue
try:
data = json.loads(candidate.read_text(encoding="utf-8"))
except Exception:
return None
port = data.get("api_port")
if not port:
return None
scheme = data.get("api_scheme") or "http"
host = data.get("api_host") or "localhost"
if host in ("0.0.0.0", "::", ""):
host = "localhost"
return f"{scheme}://{host}:{int(port)}/api/v1"
return None


API_BASE = (
os.environ.get("THE_LAB_API_URL")
or _api_base_from_runtime()
or "http://localhost:8000/api/v1"
)

# Self-signed localhost https can't be verified. The agent launcher sets
# THE_LAB_API_INSECURE=1 when it points us at such an endpoint; in that case we
Expand Down