From 46420aeac86852e1e2daa6d284f719e718efe3fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 03:52:19 +0000 Subject: [PATCH 1/2] feat: container-reachable MCP host for Docker harnesses Separate bind host, advertised host, and port so a Linux container (stock grok-build on Docker Desktop) can reach host-side RLE MCP while RimAPI stays on localhost:8765. Local defaults remain 127.0.0.1 + ephemeral port. Co-authored-by: Jason --- .env.example | 12 +++ CLAUDE.md | 2 + CONTRIBUTING.md | 20 +++- docker/README.md | 19 ++++ docs/harness-plugins.md | 7 +- src/rle/config.py | 18 ++++ src/rle/harness/cli_base.py | 45 ++++++++- src/rle/mcp/__init__.py | 9 +- src/rle/mcp/host.py | 58 +++++++++-- src/rle/mcp/listen.py | 108 +++++++++++++++++++++ tests/integration/test_cli_base_harness.py | 20 ++++ tests/unit/test_config.py | 16 +++ tests/unit/test_headless_cli_options.py | 38 ++++++++ tests/unit/test_mcp_listen.py | 85 ++++++++++++++++ tests/unit/test_mcp_server.py | 31 ++++++ 15 files changed, 477 insertions(+), 11 deletions(-) create mode 100644 src/rle/mcp/listen.py create mode 100644 tests/unit/test_headless_cli_options.py create mode 100644 tests/unit/test_mcp_listen.py diff --git a/.env.example b/.env.example index 746d227..0372e36 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,15 @@ OPENROUTER_API_KEY= # --- RIMAPI (RimWorld mod) --- RIMAPI_URL=http://localhost:8765 + +# --- MCP HTTP host (coding-agent harnesses: grok-build, opencode, ...) --- +# Default: bind 127.0.0.1, ephemeral port, advertise http://127.0.0.1:/mcp +# Container-reachable (agent in Docker, RimWorld/RLE on the host — NOT --docker): +# MCP_CONTAINER_REACHABLE=true +# binds 0.0.0.0, port 8766, advertises http://host.docker.internal:8766/mcp +# Windows Docker Desktop may prompt to allow Python through the firewall. +# RimAPI stays on localhost:8765. Optional overrides: +# MCP_BIND_HOST=0.0.0.0 +# MCP_ADVERTISE_HOST=host.docker.internal +# MCP_PORT=8766 +# Same knobs as --harness-opt mcp_container_reachable=true (and mcp_bind_host / mcp_advertise_host / mcp_port). diff --git a/CLAUDE.md b/CLAUDE.md index 7c033d8..96143e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,8 @@ The `.env` file controls which LLM provider is used. Key fields: | `MODEL` | Model name as the provider expects it | `unsloth/nvidia-nemotron-3-nano-4b` | | `PROVIDER_BASE_URL` | API base URL (required for LM Studio and OpenRouter) | `http://localhost:1234/v1` | | `RIMAPI_URL` | RIMAPI mod URL | `http://localhost:8765` | +| `MCP_CONTAINER_REACHABLE` | Bind MCP on `0.0.0.0:8766`, advertise `http://host.docker.internal:8766/mcp` for Docker agents (host RimWorld). Not `--docker`. | `true` | +| `MCP_BIND_HOST` / `MCP_ADVERTISE_HOST` / `MCP_PORT` | Optional MCP listen overrides (also `--harness-opt mcp_*`) | `0.0.0.0` / `host.docker.internal` / `8766` | **Important:** For OpenRouter, `OPENAI_API_KEY` must be set to your OpenRouter API key. The OpenAI SDK reads this env var directly. The `OPENROUTER_API_KEY` field is NOT read by the SDK. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc6df23..7f5f01e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,9 +42,10 @@ release-please keeps an open "release PR" accumulating changes; merging it tags |---------|---------|-------------| | LM Studio | LLM inference | 1234 | | RimWorld + RIMAPI mod | Game state + actions | 8765 | +| MCP HTTP host (CLI harnesses) | In-process tool server for coding agents | ephemeral on 127.0.0.1; 8766 when container-reachable | | Dashboard (optional) | Live visualization | 3000 | | Tick data server (optional) | Dashboard data feed | 9000 | -| Docker (optional) | Headless benchmarks | 8765 | +| Docker (optional) | Headless RimWorld (`--docker`) | 8765 (RIMAPI only) | ### Recommended local model @@ -111,6 +112,23 @@ docker compose -f docker/docker-compose.yml up -d python scripts/run_benchmark.py --docker --runs 4 --harness felix --harness baseline --output results/docker/ ``` +`--docker` publishes **RIMAPI** from a headless RimWorld container on localhost:8765. It does not change the MCP host. + +### Container-reachable MCP (agent in Docker, game on the host) + +When the coding agent runs in a Linux container (stock grok-build on Docker Desktop) and RimWorld/RLE stay on the Windows/macOS host, the default MCP bind `127.0.0.1` is unreachable from the container. Enable host-advertised MCP: + +```bash +# .env or process env +MCP_CONTAINER_REACHABLE=true + +# or per harness +python scripts/run_scenario.py crashlanded --harness grok-build \ + --harness-opt mcp_container_reachable=true +``` + +That binds `0.0.0.0:8766` and hands the agent `http://host.docker.internal:8766/mcp`. RimAPI remains `localhost:8765`. Windows Docker Desktop / the firewall may prompt to allow Python on 8766 — allow it. Override bind/advertise/port with `MCP_BIND_HOST`, `MCP_ADVERTISE_HOST`, `MCP_PORT` or the matching `--harness-opt` keys. Do not point this at `--docker` (that path is RimWorld-in-container, not agent-in-container). + ### OpenRouter (cloud, no local GPU needed) ```bash diff --git a/docker/README.md b/docker/README.md index 4ebf76a..f1a58c7 100644 --- a/docker/README.md +++ b/docker/README.md @@ -86,6 +86,25 @@ docker run -d --name rle-rimworld \ curl http://localhost:8765/api/v1/game/state ``` +## Container-reachable MCP (agent in Docker, RimWorld on the host) + +`--docker` above is **RimWorld in a container**. The other common Desktop layout is the reverse: RimWorld + RIMAPI on the Windows/macOS host (`localhost:8765`), and a stock coding-agent image (e.g. grok-build) talking to RLE's in-process MCP server. + +RLE defaults to binding MCP on `127.0.0.1` with an ephemeral port, which a Linux container cannot reach. For that layout: + +```bash +MCP_CONTAINER_REACHABLE=true \ + python scripts/run_scenario.py crashlanded --harness grok-build +# or: --harness-opt mcp_container_reachable=true +``` + +| Process | Where | URL | +|---------|--------|-----| +| RIMAPI (game) | host | `http://localhost:8765` — unchanged | +| MCP HTTP host | host, bind `0.0.0.0:8766` | advertised to the agent as `http://host.docker.internal:8766/mcp` | + +Windows Docker Desktop (and the OS firewall) may prompt to allow Python to listen on 8766; allow it. Override with `MCP_BIND_HOST` / `MCP_ADVERTISE_HOST` / `MCP_PORT`. This is host-side RLE config, not a Grok Dockerfile in this repo (third-party harnesses stay in their own packages). + ## Known Issues - **IPv6 loopback binding**: RIMAPI's Mono HttpListener binds to `[::1]:8765` inside the container despite `serverIP=0.0.0.0` config. Docker port forwarding can't reach `::1`. Workaround: access RIMAPI from inside the container or fix the HttpListener binding in RIMAPI fork. diff --git a/docs/harness-plugins.md b/docs/harness-plugins.md index 1931b26..1376a0e 100644 --- a/docs/harness-plugins.md +++ b/docs/harness-plugins.md @@ -97,7 +97,12 @@ and continues. Any other exception is treated as a bug and propagates. short idle grace), drains the ledger into `StepResult`, applies `turn_timeout_s`, and records latency/cost/deliberation log. Options extend `HeadlessCliOptions` (`model`, `turn_timeout_s`, `idle_grace_s`, - `extra_instructions`). Needs the `mcp` extra. + `extra_instructions`, plus MCP listen overrides `mcp_container_reachable`, + `mcp_bind_host`, `mcp_advertise_host`, `mcp_port`). `setup()` starts the + MCP host on the **bind** address and passes the **advertised** URL to + `start_agent`. Default is `127.0.0.1` + ephemeral port; container-reachable + mode binds `0.0.0.0:8766` and advertises `http://host.docker.internal:8766/mcp`. + Needs the `mcp` extra. - `rle.testing.scripted_agent.ScriptedMcpHarness` — a fake coding agent that plays a fixed tool script through the MCP server. Return it from `plugin.smoke()` so your package's CI exercises the full round trip diff --git a/src/rle/config.py b/src/rle/config.py index cdc06bd..d72804d 100644 --- a/src/rle/config.py +++ b/src/rle/config.py @@ -45,6 +45,24 @@ class RLEConfig(BaseSettings): log_level: str = "INFO" docker_image: str = "rle-headless:latest" docker_port: int = 8765 + """Published RIMAPI port for ``--docker`` (headless RimWorld in a container). + Unrelated to the MCP HTTP server; do not reuse 8765 for MCP.""" + mcp_container_reachable: bool = False + """Bind MCP on ``0.0.0.0`` and advertise ``http://host.docker.internal:/mcp`` + so a Docker agent (e.g. stock grok-build on Docker Desktop) can reach a + host-side RLE process. Does **not** change ``--docker`` / RIMAPI (8765). + Env: ``MCP_CONTAINER_REACHABLE``. CLI harnesses also accept + ``--harness-opt mcp_container_reachable=true``.""" + mcp_bind_host: str | None = None + """Interface the MCP HTTP server listens on. Unset: ``127.0.0.1`` locally, + ``0.0.0.0`` when ``mcp_container_reachable``. Env: ``MCP_BIND_HOST``.""" + mcp_advertise_host: str | None = None + """Hostname put in the MCP URL handed to the agent. Unset: ``127.0.0.1`` + locally, ``host.docker.internal`` when container-reachable. + Env: ``MCP_ADVERTISE_HOST``.""" + mcp_port: int | None = None + """MCP HTTP port. Unset: ephemeral locally, ``8766`` when container-reachable + (never 8765). ``0`` forces ephemeral. Env: ``MCP_PORT``.""" hf_token: str | None = None """Fine-grained HuggingFace write token (HF_TOKEN in .env) for dataset pushes.""" hf_dataset_repo: str = "AppSprout/rle-benchmarks" diff --git a/src/rle/harness/cli_base.py b/src/rle/harness/cli_base.py index 15c71d2..bc3f959 100644 --- a/src/rle/harness/cli_base.py +++ b/src/rle/harness/cli_base.py @@ -21,10 +21,12 @@ from pydantic import BaseModel, ConfigDict, Field +from rle.config import RLEConfig from rle.harness.brief import ScenarioBrief, build_brief from rle.harness.protocol import BaseHarness, HarnessContext, HarnessStepError, StepResult from rle.mcp.host import McpHost from rle.mcp.ledger import TickLedger +from rle.mcp.listen import McpListenSettings, first_host, first_not_none, resolve_mcp_listen from rle.mcp.server import build_server from rle.mcp.session import McpSession from rle.orchestration.action_executor import ActionExecutor @@ -71,6 +73,42 @@ class HeadlessCliOptions(BaseModel): default="", description="Appended to every turn prompt (harness-side prompt engineering).", ) + mcp_container_reachable: bool | None = Field( + default=None, + description=( + "Bind MCP on 0.0.0.0 and advertise http://host.docker.internal:/mcp " + "so a Docker agent can reach host RimWorld/RLE. None inherits RLEConfig / " + "MCP_CONTAINER_REACHABLE. Does not affect --docker (RIMAPI in a container)." + ), + ) + mcp_bind_host: str | None = Field( + default=None, + description="MCP listen address. None inherits RLEConfig / MCP_BIND_HOST.", + ) + mcp_advertise_host: str | None = Field( + default=None, + description=( + "Hostname in the MCP URL given to the agent. None inherits " + "RLEConfig / MCP_ADVERTISE_HOST." + ), + ) + mcp_port: int | None = Field( + default=None, + description=( + "MCP listen port (8766 in container-reachable mode; 0 = ephemeral). " + "None inherits RLEConfig / MCP_PORT." + ), + ) + + def mcp_listen(self, config: RLEConfig) -> McpListenSettings: + """Merge harness-opt overrides onto RLEConfig, then apply mode defaults.""" + container = first_not_none(self.mcp_container_reachable, config.mcp_container_reachable) + return resolve_mcp_listen( + container_reachable=bool(container), + bind_host=first_host(self.mcp_bind_host, config.mcp_bind_host), + advertise_host=first_host(self.mcp_advertise_host, config.mcp_advertise_host), + port=first_not_none(self.mcp_port, config.mcp_port), + ) @dataclass @@ -140,8 +178,13 @@ async def setup(self, ctx: HarnessContext) -> None: self._session = McpSession( client=ctx.client, executor=ActionExecutor(ctx.client), ledger=ledger, emit=ctx.emit, ) - self._host = McpHost(build_server(self._session)) + listen = self.options.mcp_listen(ctx.config) + self._host = McpHost(build_server(self._session), listen) url = await self._host.start() + logger.info( + "MCP host listening on %s:%s, advertising %s", + self._host.bind_host, self._host.port, url, + ) await self.start_agent(url) self._agent_started = True diff --git a/src/rle/mcp/__init__.py b/src/rle/mcp/__init__.py index 0eb39cc..157b3da 100644 --- a/src/rle/mcp/__init__.py +++ b/src/rle/mcp/__init__.py @@ -7,6 +7,13 @@ """ from rle.mcp.ledger import NoActiveTickError, TickLedger +from rle.mcp.listen import McpListenSettings, resolve_mcp_listen from rle.mcp.session import McpSession -__all__ = ["McpSession", "NoActiveTickError", "TickLedger"] +__all__ = [ + "McpListenSettings", + "McpSession", + "NoActiveTickError", + "TickLedger", + "resolve_mcp_listen", +] diff --git a/src/rle/mcp/host.py b/src/rle/mcp/host.py index 288cce8..93ad4be 100644 --- a/src/rle/mcp/host.py +++ b/src/rle/mcp/host.py @@ -2,6 +2,10 @@ Harnesses that drive an external coding agent start one of these so the agent's MCP client and the environment share a single ledger in memory. + +Bind address and advertised URL are separate: local runs bind and advertise +``127.0.0.1``; container-reachable mode binds ``0.0.0.0`` and advertises +``host.docker.internal`` so a Docker agent can reach a host-side server. """ from __future__ import annotations @@ -12,7 +16,14 @@ import uvicorn from mcp.server.mcpserver import MCPServer -MCP_PATH = "/mcp" +from rle.mcp.listen import ( + MCP_PATH, + McpListenSettings, + advertised_mcp_url, + resolve_mcp_listen, +) + +__all__ = ["MCP_PATH", "McpHost", "free_port"] def free_port(host: str = "127.0.0.1") -> int: @@ -22,16 +33,45 @@ def free_port(host: str = "127.0.0.1") -> int: class McpHost: - def __init__(self, server: MCPServer, *, host: str = "127.0.0.1", port: int = 0) -> None: + def __init__( + self, + server: MCPServer, + settings: McpListenSettings | None = None, + *, + bind_host: str | None = None, + advertise_host: str | None = None, + port: int | None = None, + ) -> None: + if settings is not None and ( + bind_host is not None or advertise_host is not None or port is not None + ): + raise TypeError("pass settings or keyword fields, not both") + listen = settings or resolve_mcp_listen( + bind_host=bind_host, advertise_host=advertise_host, port=port, + ) + self._bind_host = listen.bind_host + self._advertise_host = listen.advertise_host + self._port = listen.port or free_port(listen.bind_host) self._server = server - self._host = host - self._port = port or free_port(host) self._uvicorn: uvicorn.Server | None = None self._task: asyncio.Task[None] | None = None + @property + def bind_host(self) -> str: + return self._bind_host + + @property + def advertise_host(self) -> str: + return self._advertise_host + + @property + def port(self) -> int: + return self._port + @property def url(self) -> str: - return f"http://{self._host}:{self._port}{MCP_PATH}" + """URL handed to the agent — always the advertised host, not the bind address.""" + return advertised_mcp_url(self._advertise_host, self._port) @property def running(self) -> bool: @@ -40,10 +80,14 @@ def running(self) -> bool: async def start(self, *, timeout_s: float = 10.0) -> str: if self.running: return self.url + # ``host`` here is the Host-header identity the MCP SDK uses to decide + # whether to lock DNS-rebinding protection to loopback. Pass the + # advertised host so container clients sending Host: + # host.docker.internal are not rejected. uvicorn still binds bind_host. app = self._server.streamable_http_app( - streamable_http_path=MCP_PATH, host=self._host, stateless_http=True, + streamable_http_path=MCP_PATH, host=self._advertise_host, stateless_http=True, ) - config = uvicorn.Config(app, host=self._host, port=self._port, log_level="warning") + config = uvicorn.Config(app, host=self._bind_host, port=self._port, log_level="warning") self._uvicorn = uvicorn.Server(config) self._task = asyncio.create_task(self._uvicorn.serve()) deadline = asyncio.get_running_loop().time() + timeout_s diff --git a/src/rle/mcp/listen.py b/src/rle/mcp/listen.py new file mode 100644 index 0000000..d098919 --- /dev/null +++ b/src/rle/mcp/listen.py @@ -0,0 +1,108 @@ +"""Bind vs advertise settings for the in-process MCP HTTP host. + +A Linux Docker container cannot reach ``127.0.0.1`` on the host. Container- +reachable mode binds ``0.0.0.0`` and advertises ``host.docker.internal`` so +an agent inside the container (stock grok-build on Docker Desktop, other +CLI harnesses) can call the host-side MCP server. + +This is the opposite topology of ``--docker`` (headless RimWorld in a +container, RIMAPI published on localhost:8765). Enabling container-reachable +MCP does not change RIMAPI; the game stays on ``localhost:8765``. +""" + +from __future__ import annotations + +from typing import TypeVar + +from pydantic import BaseModel, ConfigDict + +MCP_PATH = "/mcp" + +LOCAL_BIND_HOST = "127.0.0.1" +LOCAL_ADVERTISE_HOST = "127.0.0.1" +LOCAL_PORT = 0 # ephemeral + +CONTAINER_BIND_HOST = "0.0.0.0" +CONTAINER_ADVERTISE_HOST = "host.docker.internal" +CONTAINER_PORT = 8766 + +_WILDCARD_BIND_HOSTS = frozenset({"0.0.0.0", "::", "[::]"}) + +_T = TypeVar("_T") + + +class McpListenSettings(BaseModel): + """Where the MCP HTTP server listens vs what agents are told to call.""" + + model_config = ConfigDict(frozen=True) + + bind_host: str + advertise_host: str + port: int + """0 means pick an ephemeral port at bind time.""" + + def url(self) -> str: + return advertised_mcp_url(self.advertise_host, self.port) + + def with_port(self, port: int) -> McpListenSettings: + return McpListenSettings( + bind_host=self.bind_host, advertise_host=self.advertise_host, port=port, + ) + + +def advertised_mcp_url(host: str, port: int) -> str: + """Build the streamable-HTTP MCP URL agents should be given.""" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + return f"http://{host}:{port}{MCP_PATH}" + + +def first_not_none(*values: _T | None) -> _T | None: + for value in values: + if value is not None: + return value + return None + + +def first_host(*values: str | None) -> str | None: + for value in values: + if value is not None and value.strip(): + return value.strip() + return None + + +def resolve_mcp_listen( + *, + container_reachable: bool = False, + bind_host: str | None = None, + advertise_host: str | None = None, + port: int | None = None, +) -> McpListenSettings: + """Resolve bind / advertise / port from an optional container-reachable mode. + + Explicit values always win. Unset fields take mode defaults: + + * local (default): bind and advertise ``127.0.0.1``, ephemeral port + * container-reachable: bind ``0.0.0.0``, advertise + ``host.docker.internal``, port ``8766`` (not 8765 — that is RIMAPI) + """ + resolved_bind = first_host(bind_host) or ( + CONTAINER_BIND_HOST if container_reachable else LOCAL_BIND_HOST + ) + if advertise_host is not None and advertise_host.strip(): + resolved_advertise = advertise_host.strip() + elif container_reachable: + resolved_advertise = CONTAINER_ADVERTISE_HOST + elif resolved_bind in _WILDCARD_BIND_HOSTS: + resolved_advertise = LOCAL_ADVERTISE_HOST + else: + resolved_advertise = resolved_bind + if port is not None: + resolved_port = port + else: + resolved_port = CONTAINER_PORT if container_reachable else LOCAL_PORT + return McpListenSettings( + bind_host=resolved_bind, + advertise_host=resolved_advertise, + port=resolved_port, + ) diff --git a/tests/integration/test_cli_base_harness.py b/tests/integration/test_cli_base_harness.py index 7a8a7bf..3d0b7b2 100644 --- a/tests/integration/test_cli_base_harness.py +++ b/tests/integration/test_cli_base_harness.py @@ -73,6 +73,26 @@ async def test_describe_includes_agent_versions(self) -> None: await harness.teardown() assert info == {"harness": "my-tool", "model": "some/model", "scripted_agent": "1"} + async def test_setup_hands_agent_advertised_url(self) -> None: + harness = scripted.ScriptedMcpHarness( + cli_base.HeadlessCliOptions( + mcp_bind_host="127.0.0.1", + mcp_advertise_host="host.docker.internal", + mcp_port=0, + ), + ) + async with _env() as (client, _mock): + ctx = HarnessContext(config=RLEConfig(tick_interval=0.0), client=client) + await harness.setup(ctx) + try: + assert harness.mcp_url == ( + f"http://host.docker.internal:{harness._host.port}/mcp" # type: ignore[union-attr] + ) + assert harness._url == harness.mcp_url + assert harness._host.bind_host == "127.0.0.1" # type: ignore[union-attr] + finally: + await harness.teardown() + class _NeverEndsTurn(scripted.ScriptedMcpHarness): # type: ignore[misc] name: ClassVar[str] = "never-ends" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index a437770..6ce80f1 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -39,6 +39,22 @@ def test_harness_defaults(self) -> None: assert config.harness == "felix" assert config.harness_options == {} assert config.tick_timeout_s is None + assert config.mcp_container_reachable is False + assert config.mcp_bind_host is None + assert config.mcp_advertise_host is None + assert config.mcp_port is None + assert config.docker_port == 8765 + + def test_mcp_listen_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MCP_CONTAINER_REACHABLE", "true") + monkeypatch.setenv("MCP_BIND_HOST", "0.0.0.0") + monkeypatch.setenv("MCP_ADVERTISE_HOST", "host.docker.internal") + monkeypatch.setenv("MCP_PORT", "8766") + config = RLEConfig() + assert config.mcp_container_reachable is True + assert config.mcp_bind_host == "0.0.0.0" + assert config.mcp_advertise_host == "host.docker.internal" + assert config.mcp_port == 8766 def test_no_felix_symbols_on_config(self) -> None: assert not hasattr(RLEConfig(), "get_provider") diff --git a/tests/unit/test_headless_cli_options.py b/tests/unit/test_headless_cli_options.py new file mode 100644 index 0000000..1bf493b --- /dev/null +++ b/tests/unit/test_headless_cli_options.py @@ -0,0 +1,38 @@ +"""HeadlessCliOptions merge of MCP listen settings onto RLEConfig.""" + +from __future__ import annotations + +import pytest + +from rle.config import RLEConfig + +cli_base = pytest.importorskip("rle.harness.cli_base") + + +class TestOptionsMergeOntoConfig: + def test_default_options_follow_config(self) -> None: + opts = cli_base.HeadlessCliOptions() + config = RLEConfig(mcp_container_reachable=True, mcp_port=9002) + listen = opts.mcp_listen(config) + assert listen.bind_host == "0.0.0.0" + assert listen.advertise_host == "host.docker.internal" + assert listen.port == 9002 + + def test_harness_opt_overrides_config(self) -> None: + opts = cli_base.HeadlessCliOptions( + mcp_container_reachable=False, + mcp_advertise_host="agent.example", + mcp_port=0, + ) + config = RLEConfig(mcp_container_reachable=True, mcp_advertise_host="from-config") + listen = opts.mcp_listen(config) + assert listen.bind_host == "127.0.0.1" + assert listen.advertise_host == "agent.example" + assert listen.port == 0 + + def test_false_harness_opt_disables_config_flag(self) -> None: + opts = cli_base.HeadlessCliOptions(mcp_container_reachable=False) + config = RLEConfig(mcp_container_reachable=True) + listen = opts.mcp_listen(config) + assert listen.bind_host == "127.0.0.1" + assert listen.port == 0 diff --git a/tests/unit/test_mcp_listen.py b/tests/unit/test_mcp_listen.py new file mode 100644 index 0000000..2bbd262 --- /dev/null +++ b/tests/unit/test_mcp_listen.py @@ -0,0 +1,85 @@ +"""Bind vs advertise URL construction for the MCP HTTP host (no live Docker).""" + +from __future__ import annotations + +from rle.mcp.listen import ( + CONTAINER_ADVERTISE_HOST, + CONTAINER_BIND_HOST, + CONTAINER_PORT, + LOCAL_ADVERTISE_HOST, + LOCAL_BIND_HOST, + LOCAL_PORT, + McpListenSettings, + advertised_mcp_url, + resolve_mcp_listen, +) + + +class TestAdvertisedUrl: + def test_ipv4_and_hostname(self) -> None: + assert advertised_mcp_url("127.0.0.1", 3456) == "http://127.0.0.1:3456/mcp" + assert ( + advertised_mcp_url(CONTAINER_ADVERTISE_HOST, CONTAINER_PORT) + == "http://host.docker.internal:8766/mcp" + ) + + def test_ipv6_is_bracketed(self) -> None: + assert advertised_mcp_url("::1", 8766) == "http://[::1]:8766/mcp" + assert advertised_mcp_url("[::1]", 8766) == "http://[::1]:8766/mcp" + + def test_settings_url(self) -> None: + settings = McpListenSettings( + bind_host=CONTAINER_BIND_HOST, + advertise_host=CONTAINER_ADVERTISE_HOST, + port=CONTAINER_PORT, + ) + assert settings.url() == "http://host.docker.internal:8766/mcp" + assert settings.with_port(9001).port == 9001 + + +class TestResolveMcpListen: + def test_local_defaults_unchanged(self) -> None: + settings = resolve_mcp_listen() + assert settings.bind_host == LOCAL_BIND_HOST + assert settings.advertise_host == LOCAL_ADVERTISE_HOST + assert settings.port == LOCAL_PORT + assert settings.bind_host == "127.0.0.1" + assert settings.advertise_host == "127.0.0.1" + assert settings.port == 0 + + def test_container_reachable_defaults(self) -> None: + settings = resolve_mcp_listen(container_reachable=True) + assert settings.bind_host == CONTAINER_BIND_HOST + assert settings.advertise_host == CONTAINER_ADVERTISE_HOST + assert settings.port == CONTAINER_PORT + assert settings.url() == "http://host.docker.internal:8766/mcp" + + def test_explicit_values_win_over_container_defaults(self) -> None: + settings = resolve_mcp_listen( + container_reachable=True, + bind_host="192.168.1.10", + advertise_host="mcp.lan", + port=9001, + ) + assert (settings.bind_host, settings.advertise_host, settings.port) == ( + "192.168.1.10", "mcp.lan", 9001, + ) + + def test_explicit_port_zero_stays_ephemeral_in_container_mode(self) -> None: + settings = resolve_mcp_listen(container_reachable=True, port=0) + assert settings.port == 0 + assert settings.bind_host == "0.0.0.0" + + def test_wildcard_bind_does_not_advertise_zero(self) -> None: + settings = resolve_mcp_listen(bind_host="0.0.0.0") + assert settings.bind_host == "0.0.0.0" + assert settings.advertise_host == "127.0.0.1" + + def test_custom_bind_is_advertised_when_not_wildcard(self) -> None: + settings = resolve_mcp_listen(bind_host="192.168.1.5") + assert settings.advertise_host == "192.168.1.5" + + def test_blank_host_treated_as_unset(self) -> None: + settings = resolve_mcp_listen(container_reachable=True, bind_host=" ", advertise_host="") + assert settings.bind_host == "0.0.0.0" + assert settings.advertise_host == "host.docker.internal" diff --git a/tests/unit/test_mcp_server.py b/tests/unit/test_mcp_server.py index 8fa9689..949d6a3 100644 --- a/tests/unit/test_mcp_server.py +++ b/tests/unit/test_mcp_server.py @@ -144,6 +144,9 @@ async def test_client_over_streamable_http(self) -> None: url = await host.start() try: assert url.endswith("/mcp") + assert url.startswith("http://127.0.0.1:") + assert host.bind_host == "127.0.0.1" + assert host.advertise_host == "127.0.0.1" async with mcp_client.Client(url) as client: tools = await client.list_tools() assert any(t.name == "end_turn" for t in tools.tools) @@ -155,3 +158,31 @@ async def test_client_over_streamable_http(self) -> None: finally: await host.stop() assert not host.running + + async def test_advertises_host_while_binding_loopback(self) -> None: + async with _session() as (session, _mock): + host = mcp_host.McpHost( + mcp_server.build_server(session), + bind_host="127.0.0.1", + advertise_host="host.docker.internal", + port=0, + ) + advertised = await host.start() + try: + assert advertised == ( + f"http://host.docker.internal:{host.port}/mcp" + ) + assert host.bind_host == "127.0.0.1" + bind_url = f"http://127.0.0.1:{host.port}/mcp" + async with mcp_client.Client(bind_url) as client: + tools = await client.list_tools() + assert any(t.name == "end_turn" for t in tools.tools) + finally: + await host.stop() + + def test_settings_and_kwargs_are_exclusive(self) -> None: + from rle.mcp.listen import resolve_mcp_listen + + settings = resolve_mcp_listen() + with pytest.raises(TypeError, match="not both"): + mcp_host.McpHost(mcp_server.build_server, settings, bind_host="0.0.0.0") From 39faf6891020ef5a55ced15c1dafe31dd148b1c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 03:52:54 +0000 Subject: [PATCH 2/2] fix: drop third-party harness names from MCP listen comments The harness-boundary CI check forbids grok-build / opencode in src/. Co-authored-by: Jason --- src/rle/config.py | 4 ++-- src/rle/mcp/listen.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/rle/config.py b/src/rle/config.py index d72804d..732d500 100644 --- a/src/rle/config.py +++ b/src/rle/config.py @@ -49,8 +49,8 @@ class RLEConfig(BaseSettings): Unrelated to the MCP HTTP server; do not reuse 8765 for MCP.""" mcp_container_reachable: bool = False """Bind MCP on ``0.0.0.0`` and advertise ``http://host.docker.internal:/mcp`` - so a Docker agent (e.g. stock grok-build on Docker Desktop) can reach a - host-side RLE process. Does **not** change ``--docker`` / RIMAPI (8765). + so a Docker coding-agent container can reach a host-side RLE process. + Does **not** change ``--docker`` / RIMAPI (8765). Env: ``MCP_CONTAINER_REACHABLE``. CLI harnesses also accept ``--harness-opt mcp_container_reachable=true``.""" mcp_bind_host: str | None = None diff --git a/src/rle/mcp/listen.py b/src/rle/mcp/listen.py index d098919..d742779 100644 --- a/src/rle/mcp/listen.py +++ b/src/rle/mcp/listen.py @@ -2,8 +2,7 @@ A Linux Docker container cannot reach ``127.0.0.1`` on the host. Container- reachable mode binds ``0.0.0.0`` and advertises ``host.docker.internal`` so -an agent inside the container (stock grok-build on Docker Desktop, other -CLI harnesses) can call the host-side MCP server. +an agent inside the container can call the host-side MCP server. This is the opposite topology of ``--docker`` (headless RimWorld in a container, RIMAPI published on localhost:8765). Enabling container-reachable