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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>/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).
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 19 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion docs/harness-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/rle/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>/mcp``
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
"""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"
Expand Down
45 changes: 44 additions & 1 deletion src/rle/harness/cli_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:<port>/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
Expand Down Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion src/rle/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
58 changes: 51 additions & 7 deletions src/rle/mcp/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down
Loading
Loading