# Roundhouse MVP6 — Build Architecture & Work Breakdown

**File: `mvp1/MVP6-SPEC.md`** (beside `roundhouse.py`; `MVP6.md` at repo root stays the contract — its acceptance checklist is the definition of done, its Out-of-scope list is binding).

Grounded in: `MVP6.md` (contract), E-series (MVP2), F-series (MVP3), G-series (MVP4), H-series (MVP5) — all stand, nothing here re-opens them; `mvp1/roundhouse.py` @ c2e3193 (7078 lines). Recon findings that shaped this spec: **(1)** The E8 auth gate fires in `do_POST` *before* dispatch (line 2209): unarmed → 403 `{"error":"read_only_mode","detail":"launch with --actuate to enable rollouts"}`; missing/bad bearer → 401 `{"error":"unauthorized"}` + `WWW-Authenticate: Bearer`. Both are JSON bodies — the MCP wrapper surfaces them as *data* with zero special-casing. **(2)** Not every error body is JSON: `error_404`/`error_405` (lines 2865/2858) and `serve_unit_detail`'s missing-unit path return **text/plain** (`404 Not Found`, `405 Method Not Allowed`), while `serve_rollout`'s 404 is JSON. Result shaping therefore needs a non-JSON fallback (I2's `raw` rule) or unit_detail-of-a-typo crashes the wrapper. **(3)** `take_snapshot` (line 3429) already injects `mode` (`'actuate'|'read-only'`) and `rollout` (public record or null) into the `/api/units` snapshot — fleet_status's mode flag (I7) and operation summary (I5) cost zero new Roundhouse code. **(4)** `X-Roundhouse-Requester` is read by exactly one handler, `handle_warm` (line 3160, H7 sanitization: keep `[A-Za-z0-9._@ -]`, truncate 64, empty → `'token'`) — the MCP server sends the header on the `warm` tool only; sending it elsewhere would imply attribution the server does not record. **(5)** `tests/test_actuation.py::TestRoutesAuth` (line 783) is the reusable in-process-server harness: `ThreadingHTTPServer` on an ephemeral port, `MagicMock(spec=Watcher)` with a real `threading.Lock`, module-global `ACTUATE_ARMED`/`TOKEN` set for armed classes, gateway monkeypatch + recorder for actuation (line 5517 pattern). `test_mcp.py` copies this pattern instead of inventing one. **(6)** The MCP stdio transport per the MCP spec is **newline-delimited** JSON-RPC (one message per line, messages MUST NOT contain embedded newlines); `Content-Length` framing is LSP legacy and is what Claude Code does *not* speak on stdio — a Content-Length server registers cleanly and then hangs forever on `initialize`. This is Risk #1. **(7)** All GET routes are unauthenticated by design (MVP2 E8) — read tools never need the token, so a token-less registration is fully functional read-only, which is exactly the live-boltzmann acceptance row.

## 1. GLOBAL DECISIONS (I-series; implementers must not re-open them)

- **I1 — One file, `mvp1/roundhouse_mcp.py`, four sections, registry-as-data.** Section order: (1) HTTP client (`call_roundhouse`), (2) result shaping (`shape_result`, `shape_fleet_status`), (3) the tool registry `TOOLS` — an ordered dict `name -> {description, schema, method, path, action, body_args, shaper, send_requester}` that is the **single source of truth**: `tools/list` derives its wire list from `description`+`schema`; `tools/call` dispatches from `method`+`path`+`body_args`; nothing about a tool lives outside its row (I3's schemas are the row's `schema` value verbatim), (4) JSON-RPC framing loop + method handlers + `main()`. No classes needed except none — module-level functions and one `dict` of connection state (`{'url', 'client_name'}`). `roundhouse.py` is **not modified** (contract: diff empty; no route lacks a machine-readable field this spec needs — recon 1–4 confirm).
- **I2 — Result shaping, frozen: every tool returns exactly ONE content block `{"type":"text","text": <pretty-printed JSON>}`.** The JSON is `json.dumps(result, indent=2, ensure_ascii=False)` where `result` is the (possibly shaped) response dict with **`"http_status": <int>` injected as a top-level key** (injection wins on key collision — no route emits that key today; a future one losing to the wrapper's is the documented rule). Non-JSON response bodies (recon 2) become `{"http_status": N, "raw": "<decoded body text>"}`. **Refusals are data, not errors:** every HTTP response with status < 500 — including 400/401/403/404/409/422 — returns `isError: false`; the agent reads the structured body (claimants, fit arithmetic, `read_only_mode`) and reasons. `isError: true` is reserved for: HTTP 5xx (body still included, same shape), and transport failure (I8). This is the refusal-fidelity acceptance row: the tool-result JSON minus `http_status` must equal the HTTP body byte-for-byte in field terms. Pretty-printing is safe on the wire because the block text is a JSON *string* inside the compact single-line JSON-RPC response — its newlines are `\n` escapes, never raw bytes (see §3, and Risk #1).
- **I3 — Input schemas frozen in §4, one per tool, `"additionalProperties": false` everywhere, no `$ref`, no `definitions`.** The server validates arguments itself with a minimal hand-rolled checker covering exactly: `type` on the top level and on declared properties (`string`/`boolean`/`object`/`array` + array `items.type`), `required`, `additionalProperties: false`, `minProperties`. Violation → JSON-RPC error `-32602` naming the offending key (protocol error, per MCP convention — the call never reaches HTTP). The published schema may carry constraints the minimal checker does not enforce (the `warm` xor, `minLength`) — Roundhouse's own 400 is the backstop and surfaces per I2. Frozen tool names (16): `fleet_status, unit_detail, port_board, deployments, routing_config, operation_status, warm_state, switch_preview, switch_execute, edit_preview, edit_rollout, set_boot, warm, warm_cancel, operation_rollback, operation_dismiss`.
- **I4 — Two-step discipline, the anti-collapse guarantee: the wrapper is STATELESS and confirm-opaque.** `switch_execute` and `edit_rollout` take `confirm` as a required string argument and pass it through verbatim; the MCP server never computes, caches, stores, or replays a confirm — the agent carries it from the preview tool result to the execute call, deliberating in between. There is no combined preview-and-execute tool, no retry-on-`preview_stale`, no auto-re-preview, no auto-polling loop inside any tool. The wrapper holds zero mutable state between requests (the registry is constant data; connection config is set once in `main()`). A refusal or 409 is returned to the agent exactly once; what happens next is the agent's decision.
- **I5 — `fleet_status` is the ONE shaping tool; everything else is passthrough + `http_status`.** Shape (from the `/api/units` snapshot): top level `{http_status, host, kernel, now, mode, mem: {total_bytes, available_bytes}, sources, self_port, self_unit, n_units, units: [...], operation}`. Each unit row keeps exactly `{unit, rung, port, alias, enabled, on_demand, retired, strategy_note, badges}` plus `port_conflict` **only when non-null** (claimant reasoning — the refusal-doctrine's read-side twin) — dropped: `description, detail, roster, since, start_ts_mono, stale, sensed_at, active_state, sub_state, n_restarts, port_source, model_file, quant_hint, ctx, mem, gate` (unit_detail has all of them; `mem` per-unit is estimate-grade noise at roster level, the host `mem` gauge stays). `strategy_note` may be null — kept (null is information: no drift). `operation` = the snapshot's `rollout` value verbatim (already the compact `rollout_public_record`, or null — recon 3). `n_units = len(units)`. **If the MVP5 review renames any warm-route or snapshot field, every passthrough tool inherits it mechanically; only `shape_fleet_status`'s keep-list and the §7 fixtures would need the one-word update.**
- **I6 — Connection: `--url` (default `http://127.0.0.1:8090`), token: env `ROUNDHOUSE_TOKEN` wins, file `~/.config/roundhouse/token` is the default path; read per-call.** Env-first because container/CI injection must be able to override a stale baked-in file without touching the filesystem; the file is the operator-default (same path `roundhouse.py` itself uses, `TOKEN_PATH` line 3501). Token is read (env lookup, else file read stripped of trailing whitespace) at each **action** tool call — picks up rotation without restarting the MCP server; the file `open()` is the single read-only open in the program (I9 counts it). Read tools never read the token (recon 7). Token absent on an action call → structured refusal, no HTTP: `{"http_status": null, "error": "no_token", "hint": "write the bearer token to ~/.config/roundhouse/token or set ROUNDHOUSE_TOKEN"}`, `isError: false`. Requester: `'mcp:' + clientInfo.name` from `initialize` (fallback `'mcp:unknown'` when absent), sanitized with the H7 charset (`[A-Za-z0-9._@ -]`, truncate 64 — note `:` is not in the charset, so the wire value is e.g. `mcp claude-code` → sanitizer keeps `mcp` + space; **therefore the prefix is `mcp-`**, which survives H7: `mcp-claude-code`), overridable per-call via the `warm` tool's optional `requester` argument (same sanitization). Sent as `X-Roundhouse-Requester` on `POST /api/warm` only (recon 4). No `--actuate` flag on the MCP server: arming is Roundhouse's, and only Roundhouse's, state.
- **I7 — No read-only probing.** The MCP server never pre-checks mode, never sends a canary request, never caches "armed". Action tools against a read-only Roundhouse simply surface the 403 `read_only_mode` body per I2 (`isError: false` — the acceptance row "structured refusal, not an exception"). Agents that want to check first call `fleet_status` and read `mode` (recon 3). Stated in every action tool's description suffix? No — stated once in `docs/MCP.md`; descriptions stay one line.
- **I8 — Error taxonomy + timeouts, frozen.** Transport failure (`ConnectionRefusedError`, `socket.timeout`/`TimeoutError`, `OSError`, DNS failure) → `isError: true`, text block `{"error": "roundhouse_unreachable", "url": "<configured url>", "detail": "<exception str>"}` (no `http_status` key — no HTTP happened). HTTP 5xx → `isError: true`, body shaped per I2 (with `http_status`). HTTP < 500 → `isError: false` per I2. Timeouts: `http.client.HTTPConnection(host, port, timeout=5)` (bounds connect), then after `getresponse()` is issued the socket timeout is raised to 30 s via `conn.sock.settimeout(30.0)` immediately after `conn.connect()` — connect 5 s, read 30 s. 30 s is generous on purpose-nothing: switch/warm/rollout POSTs answer 202 in milliseconds (the worker runs async; polling is `operation_status`'s job), previews are pure computation; the long timeout only matters when Roundhouse is dying, where a slow honest `unreachable` beats a fast false one.
- **I9 — Zero-write AST guard for `roundhouse_mcp.py`, spec'd exactly (new `mvp1/tests/test_mcp.py`).** Over `ast.parse` of the file: (a) **import allowlist**, frozen: `sys, os, json, argparse, urllib.parse, http.client` — any other `Import`/`ImportFrom` module fails the test (in particular `subprocess`, `socket`, `shutil`, `pathlib`-with-write-methods never enter; `http.client` is the only socket owner). (b) **No subprocess anywhere**: no name `subprocess`, no `os.system`/`os.popen`/`os.exec*`/`os.spawn*` attribute access. (c) **`open()` sites**: exactly one call to `open` in the whole file (the token read), and its mode argument is absent or a constant in `{'r'}` — any second `open`, any mode containing `w/a/x/+/b`-with-write fails. (d) **No file mutation attributes**: `os.remove, os.rename, os.unlink, os.mkdir, os.makedirs, os.rmdir, os.chmod, os.replace` never appear. (e) **HTTP client confinement**: every `http.client.HTTPConnection(...)` call appears only inside `call_roundhouse` (one construction site), whose host/port derive from the parsed `--url` — asserted by locating `Call` nodes whose func resolves to `HTTPConnection` and checking their enclosing `FunctionDef`. Same three-legged style as `TestWriteGuards`; the guard runs on the file's text, needs no import of the module.
- **I10 — `docs/MCP.md` outline is §6 of this spec**; T2 delivers it. `.mcp.json` stdio example, token provisioning, catalog with paired example call/result JSON (including one full refusal-reasoning walkthrough: `switch_preview` → 422 with `port_conflict` claimants → agent picks a different stop set), boundary statement, live read-only usage.
- **I11 — Framing: newline-delimited JSON-RPC 2.0 over stdio; protocolVersion negotiation, frozen.** One JSON-RPC message per line, UTF-8, `\n` terminated; **every outbound message is `json.dumps(msg, separators=(',', ':'))` — compact, no indent, exactly one line** (pretty-printing lives only inside content-block strings, I2). No `Content-Length` headers ever (recon 6). Input: read stdin line-by-line (`for line in sys.stdin` is insufficient under block buffering from some hosts — use `sys.stdin.readline()` loop, exit cleanly on `''` EOF); blank lines are skipped. `stdout` carries JSON-RPC only, flushed after every write; all logging goes to `stderr`. Line fails `json.loads` → error response `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"parse error"}}`. Top-level JSON array (batch) → `-32600 "batch not supported"`. Valid object, unknown `method`, **with** `id` → `-32601 "method not found"`; without `id` (notification) → silently ignored — never respond to any notification. `protocolVersion`: `SUPPORTED = ("2025-06-18", "2025-03-26", "2024-11-05")`; if the client's requested version is in the set, echo it; else respond `"2024-11-05"`. The server **tolerates unknown client capabilities** — the `capabilities` object in `initialize` is read for nothing and validated never (forward compatibility is the point of capability objects). `clientInfo.name` is captured for I6's requester default.

## 2. FILE LAYOUT + REGISTRY STRUCTURE

```
mvp1/
  MVP6-SPEC.md                  # this file
  roundhouse_mcp.py             # NEW — the entire deliverable surface (~450 lines)
  scripts/
    mcp-drill.sh                # NEW — container scripted-session drill + live read-only checklist
  tests/
    test_mcp.py                 # NEW — §7; no existing test file is touched
docs/
  MCP.md                        # NEW — §6
```

`roundhouse_mcp.py` internal layout (section banners in this order):

```python
#!/usr/bin/env python3
"""Roundhouse MCP server — stdio JSON-RPC wrapper over the HTTP API. Pure client:
no subprocess, no writes, no state; every action rides the existing gated routes."""
# ===== SECTION 1: HTTP CLIENT (the only socket owner; one construction site) =====
def call_roundhouse(method, path, body=None, token=None, requester=None) -> tuple[int|None, dict|str|None]
# ===== SECTION 2: RESULT SHAPING (I2 doctrine; fleet_status is the one shaper) =====
def shape_result(status, payload) -> (dict, bool)        # -> (result_json, is_error)
def shape_fleet_status(snapshot) -> dict                 # I5 keep-list
# ===== SECTION 3: TOOL REGISTRY (single source of truth; data, not code) =====
TOOLS = { ... }                                          # §4, frozen
def validate_args(schema, args) -> str|None              # I3 minimal checker; error text or None
# ===== SECTION 4: JSON-RPC LOOP (I11 framing; initialize/tools list+call/ping) =====
def handle_message(msg, state) -> dict|None              # pure-ish: testable without pipes
def main() -> int                                        # argparse (--url), readline loop
```

**Registry row shape (frozen):**

```python
TOOLS['switch_preview'] = {
    'description': 'Preview a turntable switch: fit arithmetic, checks, suggested stops, and the confirm hash for switch_execute.',
    'schema': { ... },              # §4 verbatim — served by tools/list unmodified
    'method': 'POST',               # HTTP verb
    'path': '/api/switch/preview',  # template; '{unit}'/'{rollout_id}' substituted with urllib.parse.quote(value, safe='')
    'action': True,                 # True -> bearer token attached (I6); False -> read, no token
    'body_args': ('target', 'stops'),   # argument names forwarded as the JSON body ('' tuple / None for GET)
    'shaper': None,                 # only fleet_status carries shape_fleet_status
    'send_requester': False,        # True only on 'warm' (recon 4)
}
```

Dispatch is mechanical: substitute path template from arguments, build body as `{k: arguments[k] for k in body_args if k in arguments}`, call, shape, wrap in one text block. `tools/list` is `[{'name': n, 'description': r['description'], 'inputSchema': r['schema']} for n, r in TOOLS.items()]`. A tool that exists in the registry but not in §4's frozen list (or vice versa) is a red test (§7.2).

## 3. PROTOCOL SPEC

Framing per I11. Methods (anything else: I11's `-32601`/ignore rule):

| method | kind | response `result` |
|---|---|---|
| `initialize` | request | `{"protocolVersion": <I11 negotiation>, "capabilities": {"tools": {}}, "serverInfo": {"name": "roundhouse-mcp", "version": "6.0"}}` — capture `params.clientInfo.name`; tolerate absent/unknown params entirely |
| `notifications/initialized` | notification | none (never respond) |
| `ping` | request | `{}` |
| `tools/list` | request | `{"tools": [...]}` from the registry; `params.cursor` ignored, no `nextCursor` emitted (16 tools, one page forever) |
| `tools/call` | request | `{"content": [{"type": "text", "text": <I2 JSON>}], "isError": <bool>}` |

`tools/call` handling order, frozen: (1) `params.name` not in `TOOLS` → `-32602 "unknown tool: <name>"`. (2) `params.arguments` (default `{}`) fails `validate_args` → `-32602` with the checker's message. (3) `action` row and no token (I6) → `no_token` refusal result, `isError: false`. (4) HTTP call; transport failure → I8 unreachable result, `isError: true`. (5) shape per I2 → result. **A tool call never raises out of `handle_message`**: a bug-path exception is caught at the loop and answered `-32603 "internal error: <class>"` (id preserved) — the loop itself never dies on a single message. JSON-RPC error responses always carry the request's `id` (or `null` for parse errors, per I11).

Requests are handled **sequentially in arrival order** — no threading, no interleaving; MCP clients pipeline rarely and Roundhouse answers in milliseconds; sequential is the no-surprises choice for a wrapper whose whole job is fidelity.

## 4. TOOL CATALOG (frozen: names, descriptions, schemas, wrapped routes)

Notation: schemas are the literal `inputSchema` values. `S(x)`≡`{"type":"string"}`, all objects carry `"additionalProperties": false`. Read tools first (`action: false` — work against any mode, no token):

| tool | wraps | schema | description (agent-facing, verbatim) |
|---|---|---|---|
| `fleet_status` | `GET /api/units` → **shaped** (I5) | `{"type":"object","properties":{},"additionalProperties":false}` | Fleet roster summary: host, mode (actuate/read-only), memory gauge, every unit's rung/port/alias/enablement/on-demand flag, port conflicts, and the current operation. |
| `unit_detail` | `GET /api/units/{unit}` | `{"type":"object","properties":{"unit":S},"required":["unit"],"additionalProperties":false}` | Full detail for one unit: parameter profile, engine, wrapper, comments, warnings, gate, measured memory history. |
| `port_board` | `GET /api/ports` | `{}` -object as fleet_status | Port claim board: every claimed port with claimants, conflict class, and Roundhouse's own port. |
| `deployments` | `GET /api/deployments` | `{}`-object | Deployment records: artifact, engine, param profile, load strategy, roster state, memory, per unit. |
| `routing_config` | `GET /api/routing-config.json` | `{}`-object | Live routing config (JSON): logical model entries with api_base, rung, on-demand flag, and the warm hook. |
| `operation_status` | `GET /api/rollouts/{rollout_id}` | `{"type":"object","properties":{"rollout_id":S},"required":["rollout_id"],"additionalProperties":false}` | Poll a rollout or switch by id: phase, detail, failure, rollback offer, stops, origin. |
| `warm_state` | `GET /api/warm` | `{}`-object | Warm queue state: the pending parked request and the last disposition, or nulls. |

Action tools (`action: true` — bearer attached; refusals per I2):

| tool | wraps | schema | description |
|---|---|---|---|
| `switch_preview` | `POST /api/switch/preview` | `{"type":"object","properties":{"target":S,"stops":{"type":"array","items":S}},"required":["target"],"additionalProperties":false}` | Preview a turntable switch: fit arithmetic, checks, suggested stops, and the confirm hash for switch_execute. |
| `switch_execute` | `POST /api/switch` | same + `"confirm":S`; `"required":["target","confirm"]` | Execute a previewed switch; requires the exact confirm from switch_preview (state changed since preview means re-preview). |
| `edit_preview` | `POST /api/units/{unit}/edit` | `{"type":"object","properties":{"unit":S,"edits":{"type":"object","additionalProperties":S,"minProperties":1}},"required":["unit","edits"],"additionalProperties":false}` | Preview parameter edits to a unit file: planned edits, unified diff, preflight, and the confirm hash for edit_rollout. |
| `edit_rollout` | `POST /api/units/{unit}/rollout` | same + `"confirm":S`; `"required":["unit","edits","confirm"]` | Apply previewed edits as a rollout; requires the exact confirm from edit_preview; returns a rollout_id to poll. |
| `set_boot` | `POST /api/units/{unit}/enablement` | `{"type":"object","properties":{"unit":S,"enabled":{"type":"boolean"}},"required":["unit","enabled"],"additionalProperties":false}` | Enable or disable a unit's on-boot strategy (the checkbox); refuses on enable-collision with claimants listed. |
| `warm` | `POST /api/warm` | `{"type":"object","properties":{"logical":S,"unit":S,"requester":S},"additionalProperties":false,"oneOf":[{"required":["logical"]},{"required":["unit"]}]}` | Request warm-up of an on-demand unit by logical alias or unit name (exactly one); may start a consented switch, park, or refuse with fit arithmetic. |
| `warm_cancel` | `POST /api/warm/cancel` | `{}`-object | Cancel the parked warm request, if any. |
| `operation_rollback` | `POST /api/rollouts/{rollout_id}/rollback` | `{"type":"object","properties":{"rollout_id":S},"required":["rollout_id"],"additionalProperties":false}` | Roll back a failed operation that is offering restore. |
| `operation_dismiss` | `POST /api/rollouts/{rollout_id}/dismiss` | same | Dismiss a failed operation's restore offer, releasing the slot without restoring. |

Notes binding the coder: `edits` keys are field names or `unknown:<flag>`, values always strings (`plan_edits` takes `Dict[str, str]`); the `warm` xor is advertised in-schema but enforced by Roundhouse's 400 (I3); `routing_config` wraps the `.json` twin only — the YAML route exists for LiteLLM pulls, not for agents; the `edits` object is the one place `additionalProperties` is a *type constraint* (`S`) rather than `false` — field names are open-world by design. `warm`'s `requester` argument, when present, replaces the I6 default (both sanitized). Every `body_args` tuple: `switch_preview`/`switch_execute` = `('target','stops','confirm')`∩schema, `edit_preview`/`edit_rollout` = `('edits','confirm')`∩schema (`unit` rides the path, never the body), `set_boot` = `('enabled',)`, `warm` = `('logical','unit')` (`requester` rides the header), others empty.

## 5. AUTH / CONNECTION SPEC

Per I6/I8. `main()` argparse: `--url` only (default `http://127.0.0.1:8090`); token **never** on argv (contract). URL parsed once with `urllib.parse.urlsplit`; scheme must be `http` (else exit 2 with a stderr message — TLS is out of scope and silently ignoring `https` would fake security); host/port feed the single `HTTPConnection` site. Headers sent: `Content-Type: application/json` on POSTs, `Authorization: Bearer <token>` on action calls, `X-Roundhouse-Requester` on `warm`. The wrapper never inspects 401 vs 403 — both are I2 data; `docs/MCP.md` explains the doctrine (403 = Roundhouse unarmed; 401 = token wrong/missing → check provisioning). Token file read errors (missing, unreadable) are treated as "no token" → the I6 refusal names both provisioning paths.

## 6. docs/MCP.md OUTLINE (T2 deliverable; ~1 page + catalog)

1. **What this is:** agents get the same hands as the UI, not one finger more; every action rides the gated HTTP routes; no autonomous loop — the server acts only on a connected agent's tool call (boundary statement, verbatim from MVP6.md).
2. **Registration (Claude Code):** `.mcp.json` stdio example — `{"mcpServers": {"roundhouse": {"command": "python3", "args": ["/home/mfritsche/src/roundhouse/mvp1/roundhouse_mcp.py", "--url", "http://boltzmann.fritz.box:8090"], "env": {"ROUNDHOUSE_TOKEN": "..."}}}}` — plus the file-based alternative (omit `env`, provision `~/.config/roundhouse/token`) and the note that env wins.
3. **Token provisioning:** copy from the Roundhouse host's `~/.config/roundhouse/token`; read tools need none; rotation = update env/file, next call picks it up (per-call read).
4. **Tool catalog:** the §4 table with one example call+result JSON pair per tool (fixtures from the container drill), including **the refusal-reasoning walkthrough**: `set_boot` → 422 `enable_collision` with `claimants: [...]` → agent reads the claimants, calls `port_board`, chooses a different unit/port instead of retrying — refusals arrive with `isError: false` because a refusal is data.
5. **The two-step discipline:** preview returns `confirm`; execute requires it verbatim; `preview_stale` (409) means the world moved — re-preview and re-deliberate, never loop.
6. **Read-only usage (live):** register with no token against the read-only instance; all seven read tools work; action tools answer `read_only_mode` — the live-boltzmann acceptance row's recipe.
7. **Error taxonomy table:** the I8 three rows, verbatim.

## 7. TEST SPEC — `mvp1/tests/test_mcp.py` (new file; nothing existing is touched)

Harness note: framing/dispatch tests import `roundhouse_mcp` and call `handle_message(msg, state)` directly — no pipes. The scripted sessions run the file as `subprocess.Popen([sys.executable, 'roundhouse_mcp.py', '--url', ...], stdin/stdout=PIPE)` (the *test* may use subprocess; the guard binds the server file only), writing one compact JSON line per request and reading one per response with a deadline.

1. **`TestFraming`** (direct): table — parse error line → `-32700`, `id: null`; unknown method with id → `-32601`; unknown *notification* → no response (`None`); batch array → `-32600`; `ping` → `{}`; `initialize` result carries the three keys, echoes a supported requested version, answers `"2024-11-05"` to `"1999-01-01"`, tolerates absent `capabilities`/`clientInfo`; every serialized response is a single line (no raw `\n` inside — asserted on `json.dumps(..., separators=(',',':'))` output of each case).
2. **`TestRegistry`**: `tools/list` names == the I3 frozen 16, in order; every schema has `additionalProperties: false` at top level; every row's fields complete; exactly one row with `shaper`, exactly one with `send_requester`; frozen assertion that **no tool name matches `switch$|rollout$` without its preview twin existing** (the two-step checklist row, mechanically).
3. **`TestSchemaValidation`**: per-tool accept/reject table through `validate_args` — extra key rejected everywhere; missing `required`; wrong types (`stops: "x"`, `enabled: 1`, `edits: []`, `edits: {}` via `minProperties`); `-32602` surfaces via `handle_message` with the key named.
4. **`TestShaping`** (stub HTTP server — a bare `http.server` thread scripted per-test): `http_status` injected; 4xx JSON body → `isError: false`, fields intact; 500 → `isError: true`, body intact; text/plain 404 → `{"http_status":404,"raw":"404 Not Found"}`; server down (closed port) → `isError: true`, `roundhouse_unreachable` with the url; `shape_fleet_status` keep-list golden against a full recorded snapshot fixture (drops exactly the I5 drop-list, keeps `port_conflict` only when set, `operation` passthrough, `n_units`).
5. **`TestRefusalFidelity`** (real armed in-process server, `TestRoutesAuth` pattern + armed globals + fixture units): drive a 422 (`enable_collision` via two units on one port; `not_on_demand` via unmarked warm target) **both** through direct `http.client` and through the MCP subprocess; assert the tool-result JSON minus `http_status` equals the direct body **field-for-field** (`assertEqual` on parsed dicts); same for 403 unarmed (`ACTUATE_ARMED=False` leg) and 401 bad token; all with `isError: false`.
6. **`TestScriptedSession`** (real armed in-process server, engine gateways monkeypatched to recorders per the line-5517 pattern; MCP as subprocess): the checklist session — `initialize` → `notifications/initialized` → `fleet_status` (mode `actuate`, N units) → `switch_preview` (assert `confirm` present, tick a stop) → `switch_execute` with that confirm (202, `rollout_id`) → `operation_status` polled to a terminal phase (scripted terminal write in the engine stub) → a scripted *failed* op → `operation_rollback` (202) → `set_boot` false then true (both 200, `changed` flips) → `warm` (202/200 per fixture) → `warm_state` (pending/last visible) → `warm_cancel` (200) — **every step's tool result asserted** on status + at least one body field; recorder proves actuation went through the engine gateways only.
7. **`TestZeroWriteGuard`** (AST, I9 legs a–e verbatim) + **`TestStdlibImports`** (the allowlist — doubles as the no-SDK proof) + a seeded-violation acceptance run at integration (seed `import subprocess`, a second `open(..., 'w')`, and a stray `HTTPConnection` call in a handler → three reds → unseed; red names in the commit message, MVP5 §7.1(3) style).
8. **Auth precedence** (`TestTokenSource`): env set + file present → env token on the wire (stub server records the header); env absent → file; neither → the `no_token` refusal without any HTTP request (stub records nothing).

## 8. WORK BREAKDOWN — 2 tasks, sequential T1 → T2

Why 2, not 3: MVP6 is one new file plus its tests and docs — there is no third seam. The MVP5 3-way split existed because engine, routes, and UI were three ownership domains inside two big files; here the only real boundary is *protocol machinery* vs *the action surface + proofs*, and that boundary is a registry-table line, not a file. A third task would own no file and pure overhead. T2 starts from T1's frozen interfaces (`handle_message`, `call_roundhouse`, `shape_result`, the registry row shape — all spec'd above), so no coder contact is needed; sequential because both extend the same two files.

**T1 — Server file + protocol + full registry + framing/guard tests**
- **Writes:** `roundhouse_mcp.py` **complete** — all four sections, all 16 registry rows per §4 (rows are frozen data; splitting them between tasks would only invite drift), `validate_args`; `tests/test_mcp.py` classes `TestFraming`, `TestRegistry`, `TestSchemaValidation`, `TestShaping`, `TestZeroWriteGuard`, `TestStdlibImports`, `TestTokenSource`. T1's tests prove protocol, schemas, shaping, guards, and token sourcing against stubs; T2 owns the proofs against a real armed server.
- **Self-test:** `cd mvp1 && python3 -m unittest tests.test_mcp -v` green; `printf` a 3-line initialize/list/call session into `python3 roundhouse_mcp.py --url http://127.0.0.1:1` and eyeball: one line per response, unreachable result `isError: true`; full `discover` green (no existing test may notice MVP6 exists).
- **Must not touch:** `roundhouse.py`, any existing test file, `static/`.

**T2 — Action-path proofs + scripted session + docs + drill**
- **Writes:** `tests/test_mcp.py` classes `TestRefusalFidelity`, `TestScriptedSession` (+ armed-harness plumbing); `docs/MCP.md` per §6; `scripts/mcp-drill.sh` — container drill: source `container-setup.sh` fixtures, start `roundhouse.py --serve --actuate` + fakes, run the §7.6 session via the *real* subprocess pipe against the *real* server (no engine stubs — the fake units actually switch), assert each step with `python3 - <<'EOF'` JSON checks; then the read-only leg (restart unarmed, action tool → `read_only_mode`); then the live section (operator-run, pull-only: register against read-only boltzmann, run the seven read tools). Executes the seeded-violation run (§7.7) once at integration.
- **May touch in `roundhouse_mcp.py`:** nothing but bug fixes with a failing test first (the file is T1-frozen; T2 is the adversarial consumer).
- **Self-test:** full `discover` green; `mcp-drill.sh` container leg green end-to-end.

**Shared-file ownership:** `roundhouse_mcp.py` — T1 owns it entirely; `test_mcp.py` — T1 owns §7.1–7.4+7.7–7.8 classes, T2 owns 7.5–7.6; nobody edits the other's classes.

## 9. TEST PLAN — mapped 1:1 to MVP6.md's acceptance checklist

| criterion | proven by |
|---|---|
| `tools/list` returns all tools with JSON-Schema inputs; names/schemas frozen | `TestRegistry` (frozen 16 + schema shape) + this spec §4 as the frozen text |
| Scripted stdio session, every step asserted | `TestScriptedSession` (in-process armed server) + `mcp-drill.sh` (container, real switches) |
| Refusal fidelity: 422 body complete, field-for-field vs direct HTTP | `TestRefusalFidelity` (enable_collision claimants; not_on_demand; consent_unfittable arithmetic if the fixture affords it — at minimum the two former) |
| Read tools on read-only; action tools → structured `read_only_mode` | `TestRefusalFidelity` unarmed leg + drill read-only leg + live row |
| Zero-write proof (AST: no subprocess, no write opens, socket only via the HTTP client) | `TestZeroWriteGuard` legs a–e + seeded-violation run |
| Two-step discipline: no single-call mutate for switch/edit | `TestRegistry` preview-twin assertion + §4 frozen list (no such tool exists to list) |
| `docs/MCP.md` complete per contract | T2 deliverable reviewed against §6; drill references it for registration |
| Container drill green; live boltzmann read-tools row (may remain open) | `mcp-drill.sh` both legs; live leg operator-run |
| Stdlib only, no build step, no German, no throughput figures; `roundhouse.py` diff empty | `TestStdlibImports`; `git diff --stat` empty for roundhouse.py at integration; review grep |

## 10. RISKS — top 3 mechanical-coder failure modes and the guards placed

1. **Framing drift vs Claude Code's stdio transport — the silent-death mode.** A coder who has seen LSP emits `Content-Length:` headers, or pretty-prints the JSON-RPC envelope itself (raw newlines mid-message), or logs to stdout — the server registers, `initialize` never completes or the stream desyncs, and nothing errors anywhere; it just hangs. **Guards:** I11 freezes newline-delimited compact output and stderr-only logging in normative language; `TestFraming` asserts every response serialization is one line; `TestScriptedSession`/the drill talk to the real subprocess through real pipes — a framing mistake deadlocks the pipe read and the test's deadline turns silence into red; the drill's live row is a real Claude Code registration as the final oracle.
2. **Refusals collapsed into errors (or errors into refusals).** The instinctive wrapper `raise`s on 4xx or sets `isError: true` on 422 — the agent's client then treats fit arithmetic as a tool failure and the reasoning loop the whole milestone exists for never sees the claimants; or, inverted, a 500 arrives as calm data. **Guards:** I2/I8 draw the line at exactly 500 in normative text; `TestShaping` pins all three taxonomy rows including the text/plain 404 fallback; `TestRefusalFidelity` compares field-for-field against direct HTTP so any "helpful" summarizing, key-dropping, or message-flattening is a red diff.
3. **The wrapper grows smarts: cached confirms, auto-re-preview on `preview_stale`, retry-on-409, warm polling loops.** Each one collapses the two-step discipline or turns the stateless wrapper into a second, ungated decision-maker — precisely the "no autonomous loop" boundary. **Guards:** I4 states the anti-collapse guarantee as an invariant, not advice; the registry structure has no state slot to cache into; `TestScriptedSession` asserts `switch_execute` fails 409 `preview_stale` when handed a stale confirm (fixture leg) *and* that the MCP server made exactly one HTTP call for it (stub-count leg in `TestShaping`); the AST import allowlist has no `time`/`threading`, so a sleep-and-retry loop cannot even be written without going through the guard.

**Out of scope (do not build, per contract):** HTTP/SSE MCP transports; MCP resources/prompts/sampling; multi-host; any new Roundhouse route or field; rate limiting; agent identity beyond the requester tag; the autonomous reconciler (still never); `/api/mem` and `/api/events` tools (SSE cannot ride a tool result; `unit_detail` carries the history — an `/api/mem` tool is a cut, not an oversight).

---

**Relay-worthy findings for the committer:** (1) The MCP stdio transport is newline-delimited JSON-RPC — a Content-Length server fails *silently* under Claude Code; I11 + the pipe-based session tests exist for exactly this. (2) Two GET error paths return text/plain, not JSON (`error_404`/`error_405`); I2's `raw` fallback is load-bearing, not defensive fluff. (3) `take_snapshot` already injects `mode` and the operation record, so fleet_status's mode flag and operation summary need zero Roundhouse changes — the `roundhouse.py` diff stays empty. (4) `X-Roundhouse-Requester` is only read on `/api/warm`; the H7 charset excludes `:`, so the requester default is `mcp-<client>`, not `mcp:<client>`. (5) If the parallel MVP5 review renames any warm-route field, all passthrough tools inherit it mechanically; only `shape_fleet_status`'s keep-list and test fixtures could need a touch.

> **Erratum (post-review):** §6.6's claim that a token-less registration's action tools answer `read_only_mode` is unreachable — I6/§3's frozen ordering fires the client-side `no_token` refusal before any HTTP. The docs carry the correct two-row table; the frozen ordering won.
