# ADR-0008 — MCP-to-FastAPI IPC via localhost HTTP bridge

**Status:** Accepted
**Date:** 2026-05-05
**Deciders:** JT, Claude (in dialogue)
**Supersedes:** none
**Superseded by:** none

> *This ADR was drafted during the embedded-claude-terminal Phase 11 cleanup commit. It locks in the IPC decision made in BUILD_SPEC §4 (item ADR-0005 in spec text; renumbered to 0008 because the repo already has ADRs 0001–0005).*

## Context

`recoil/workspace/mcp_server.py` is the workspace MCP server. It runs in a separate process tree from the FastAPI app (`recoil/api/main.py`) — Claude Code spawns it via `claude_desktop_config.json` / MCP discovery, while FastAPI is a long-running uvicorn process started by the editors / Console wrappers.

The two phase-7/8/9 MCP tools (`propose_action` and `get_recent_clicks`) need to emit events onto the in-memory `BUS` that lives inside FastAPI. Direct import of `BUS` from `mcp_server.py` is a no-op — the MCP process has its own `BUS` instance with no subscribers; the SSE clients are bound to FastAPI's instance.

The connection options:

1. **Localhost HTTP bridge.** `mcp_server.py` POSTs to `http://127.0.0.1:8431/api/internal/bus`; FastAPI forwards onto `BUS`.
2. **Shared memory.** `multiprocessing.shared_memory` or equivalent; both sides attach to the same buffer.
3. **Unix domain socket.** Local-only, faster than TCP, no network stack overhead.
4. **Named pipe / FIFO.**

## Decision

Localhost HTTP bridge. `POST /api/internal/bus` is the single path by which `mcp_server.py` reaches the FastAPI BUS. The route refuses non-loopback callers (`127.0.0.1` / `::1` only), returns `{ok: true}` synchronously, and surfaces forwarder failures as HTTP 4xx/5xx to the MCP caller.

## Consequences

**Sub-millisecond on localhost.** TCP-over-loopback round-trip is ~0.1–0.3ms on macOS; the BUS event itself is in-memory after that. No human-perceptible latency on the proposal-create / click-record paths.

**Zero new IPC infrastructure.** FastAPI, uvicorn, and `requests` (or `httpx`) are already linked into both processes. No `multiprocessing` shared-memory teardown bugs, no named-pipe permissions, no socket cleanup.

**Auth is `is_loopback`.** The route hard-rejects non-loopback callers. Tailscale-mesh peers cannot reach this surface even though they can reach the public FastAPI routes — the loopback check is enforced before routing.

**Single bridge.** All future MCP→FastAPI calls go through `/api/internal/bus`, not a sprawl of per-tool endpoints. New MCP tools that need to emit BUS events POST a payload here; they don't add new internal routes.

## Alternatives considered

- **Shared memory.** Rejected — premature optimisation for a sub-millisecond path; introduces lifecycle bugs (who creates the segment, who cleans up on crash) for no measurable speedup.
- **Unix socket.** Rejected — same observation; the speedup over TCP-loopback is real but invisible at this call rate (≤10/sec sustained).
- **gRPC.** Rejected — schema-and-codegen overhead for a one-way fire-and-forget channel. JSON over HTTP fits.
- **Direct `BUS` import.** Rejected — fundamentally broken; separate processes, separate `BUS` instances.

## Implementation

- `recoil/api/internal_bus_routes.py` — `POST /api/internal/bus`, loopback-only, forwards onto `BUS`.
- `recoil/api/main.py` — `app.include_router(internal_bus_router, prefix="/api")`.
- `recoil/workspace/mcp_server.py` — emits via `requests.post("http://127.0.0.1:8431/api/internal/bus", json=...)`.

## Verification

```
$ curl -s -X POST http://127.0.0.1:8431/api/internal/bus \
       -H 'content-type: application/json' \
       -d '{"scope":"chat/proposals","severity":"info","summary":"test"}'
{"ok":true}
$ curl -s -X POST http://100.105.59.118:8431/api/internal/bus -d '{}'
# (refused — non-loopback)
```
