# MCP Contract β€” `bridged`'s unified gateway > **Status: πŸ”΄ HISTORICAL DESIGN β€” do NOT use as the tool reference.** Written 2026-07-14, before > any MCP code existed. The system shipped and this page never caught up, so **its tool names, > parameter names and REST paths are wrong today**. Audited 2026-08-17; the specific drift: > > - **Tools it names that do not exist:** `bridge_read`, `bridge_cancel`. > - **Shipped tools it omits:** `bridge_poll`, `bridge_ack`, `bridge_profiles`, `bridge_whoami`. > - **Parameter names are wrong nearly everywhere** β€” it says `message`/`target`/`timeout_seconds`/ > `block` where the code takes `content`/`sessionId`/`timeoutMs`/`wait`; `text` where > `bridge_reply` takes `content`; `target` where `bridge_stop` takes `paneId`. > - **REST paths are wrong:** it says `POST /workers` and `DELETE /workers/{paneId}`; the daemon > serves `POST /members` and `DELETE /members/{paneId}`. > > **The authoritative tool surface is the live MCP schema** (each tool's own description and > parameters, as mounted), with the intentβ†’tool table in `CLAUDE.md` as the short form. Both were > checked against `mcp/BridgeMcp.java` on 2026-08-17 and are accurate. > > What is still worth reading here is **Β§6 β€” the flows and the error model** (rendezvous, > `bridge_ask`, detached delivery, the turn-done fallback). The shapes it describes are the ones > that shipped; only the names around them drifted. Rewriting this page is tracked as **CB-609**. `bridged` is the **sole communication gateway** for every Claude session in the bridge. Both the **primary** (Opus, on subscription) and every **worker** (off-subscription Claude Code) mount the *same* MCP server with a single `claude mcp add` line, and talk only through its tools. No Claude session ever addresses a broker, a peer, or the network directly. This document defines every MCP tool that face must expose, who may call it, its blocking semantics, and how it maps onto the code already in the tree. --- ## 1. Design constraints (non-negotiable) These come from the project's core invariants and bound every decision below. 1. **One server, both roles.** The primary and all workers mount an identical server. The catalog must serve both, and `bridged` must decide *who is calling* from the connection β€” never from a caller-supplied argument that could be spoofed. 2. **Subscription-safe by construction.** No MCP tool ever reads, sets, or forwards `ANTHROPIC_BASE_URL`. Mounting the bridge cannot move a session off subscription. Enforced today by [`SubscriptionGuard`](1-Architecture). 3. **Blocking rendezvous, no busy-poll.** The primary consumes a worker's reply through a *single* MCP call that `bridged` holds open β€” never a cross-turn poll loop that would burn subscription quota. 4. **Status-gated delivery.** Anything that puts text into a worker flows through the existing [`Injector`](1-Architecture): delivered only when the worker is `idle`/`blocked`, at most one message per turn. 5. **`bridged` owns policy; herdr owns PTYs.** MCP tools express *intent*; `bridged` translates it into guard checks, rendezvous bookkeeping, and herdr `agent.*` calls. --- ## 2. Topology Both faces live in the one daemon. The **north face** is MCP (this document); the **south face** is the herdr Unix socket. REST/SSE remains only for non-Claude clients and dashboards. ```mermaid flowchart LR OPUS["Opus β€” primary
(Claude Code, env CLEAN)
MCP client"] subgraph BD["bridged β€” standalone daemon"] MCP["MCP server (north face)
bridge_send Β· bridge_reply
bridge_ask Β· bridge_status Β· lifecycle"] RDV["rendezvous registry
(blocking-call waiters)"] INJ["Injector + StatusPoller
(status-gated writer)"] SOCK["herdr socket client (south face)"] MCP --> RDV RDV --> INJ INJ --> SOCK MCP --> SOCK end HERDR["herdr
panes Β· agent-status"] W["worker claude pane
ANTHROPIC_BASE_URL set
MCP client"] OPUS -->|"bridge_send (blocks)"| MCP W -.->|"bridge_reply / bridge_ask"| MCP SOCK -->|"agent.start Β· agent.send
agent.get Β· pane.close"| HERDR HERDR -->|"drives PTY"| W classDef ext fill:#2b6cb0,stroke:#1a365d,color:#ffffff; classDef core fill:#2f855a,stroke:#22543d,color:#ffffff; class OPUS,W ext class MCP,RDV,INJ,SOCK core ``` --- ## 3. Identity & addressing Because the same server is mounted by everyone, `bridged` resolves the caller's role on every request β€” this is the linchpin of the whole contract and has no code yet. - **Workers are known.** `bridged` spawns every worker ([`WorkerService`](1-Architecture)) and records its herdr session UUID / `terminal_id` on the returned [`Agent`]. When a call arrives on a connection that maps to a known worker, the caller is *that* worker β€” so **workers never pass a target**; routing is implicit. - **The primary is "not a worker".** Any connection that does not map to a known worker is treated as a primary. It addresses workers **explicitly** by `target` β€” a session UUID, a `terminal_id`, or a friendly `profile` name. - **Turn correlation.** A blocking `bridge_send` registers a *waiter* keyed by worker identity. A worker's later `bridge_reply` / `bridge_ask` on the same identity resolves that waiter. A `turn_id` is minted per exchange so a clarification round-trip (Β§6.2) rejoins the right turn. --- ## 4. Transport `bridged` is a long-lived daemon serving **multiple** concurrent clients (one primary + N workers), so a per-client stdio child is the wrong shape. The recommended transport is **streamable-HTTP / SSE** on the same bind as the REST face: ```bash # identical on primary and every worker claude mcp add --transport http bridged http://127.0.0.1:8080/mcp ``` This adds an MCP-server dependency the pom does not yet carry. See [Open decisions](#10-open-decisions). --- ## 5. Tool catalog | Tool | Caller | Blocks? | Backing (exists today?) | |---|---|---|---| | [`bridge_send`](#bridge_send) | primary | yes (default) | `Injector.enqueue` βœ… Β· rendezvous registry ❌ (CB-104) | | [`bridge_reply`](#bridge_reply) | worker | no | rendezvous ❌ Β· pane injection via `Injector` βœ… | | [`bridge_ask`](#bridge_ask) | worker | yes | reverse rendezvous ❌ | | [`bridge_status`](#bridge_status) | either | no | `AgentControl.status` βœ… Β· `Injector.activeTargets` βœ… | | [`bridge_spawn`](#lifecycle) | primary | no | `WorkerService.spawn` βœ… (`POST /workers`) | | [`bridge_list`](#lifecycle) | either | no | `WorkerService.list` βœ… (`/agents`) | | [`bridge_stop`](#lifecycle) | primary | no | `WorkerService.stop` βœ… (`DELETE /workers/{paneId}`) | | [`bridge_read`](#bridge_read) | primary | no | `AgentControl.read` βœ… | | [`bridge_cancel`](#bridge_cancel) | primary | no | β€” ❌ (future) | ### Core: delegation & rendezvous #### `bridge_send` *(primary β†’ worker β€” the headline tool, CB-104)* - **Params:** `message` (required); `target` (optional β€” defaults to the sole worker / default profile); `timeout_seconds` (default 600); `block` (default `true`); `auto_spawn` (default `true`); `turn_id` (optional β€” supplied when answering a worker's `bridge_ask`). - **Blocking (`block:true`):** enqueue `message` via the `Injector`, then hold the call open until exactly one of: - worker calls `bridge_reply` β†’ `{ outcome:"reply", text }` - worker calls `bridge_ask` β†’ `{ outcome:"question", text, turn_id }` - worker's `agent_status` reaches done/idle with no reply β†’ `{ outcome:"turn_done", text: }` - deadline elapses β†’ `{ outcome:"timeout" }` - worker gone β†’ error `worker_gone` - **Detached (`block:false`):** enqueue and return `{ outcome:"dispatched", dispatch_id }` immediately. The eventual reply is injected into the primary's idle pane (Β§6.3), or drained via `bridge_status` on a split-host primary. #### `bridge_reply` *(worker β†’ primary)* - **Params:** `text` (required); `final` (default `true`). - **Behavior:** resolve the primary waiter registered against this worker with `text`. If no waiter exists (detached delegation), `bridged` **injects the primary's idle pane** instead. Returns `{ delivered:true, mode:"resolved"|"injected" }`. No `target` β€” identity is implicit. #### `bridge_ask` *(worker β†’ primary β€” the reverse rendezvous)* - **Params:** `question` (required); `timeout_seconds`. - **Behavior:** blocks the *worker's* call. Surfaces the question to the primary (resolving its open `bridge_send` with `outcome:"question"`, or injecting its pane). When the primary answers β€” a `bridge_send` carrying the matching `turn_id` β€” that unblocks this call and returns `{ answer }` to the worker, which continues **in the same turn**. ### Worker lifecycle Thin adapters over [`WorkerService`](1-Architecture) β€” parity with the existing REST routes. - **`bridge_spawn`** β€” `{ profile? }` β†’ worker view (`sessionId`, `terminalId`, `paneId`, `status`). Guard-checked; a boundary breach returns error `subscription_boundary` (the REST `403`). - **`bridge_list`** β€” no params β†’ all workers + `agent_status`. Read-only, either role. - **`bridge_stop`** β€” `{ target }` β†’ tears down the pane and its dedicated tab. Idempotent. ### Observability #### `bridge_status` *(either role β€” the README's 4th named tool)* - **Params:** `target?`. - **Behavior:** per-worker `agent_status`, queue depth (`Injector.activeTargets`), whether a rendezvous is open, and ids. For the *calling* session it also reports/drains **pending messages addressed to me** β€” the path a split-host primary's `Stop`-hook uses to wake and collect replies without being injectable. Read-only, non-blocking. #### `bridge_read` *(primary)* - **Params:** `target`; `source` ∈ `visible | recent | recent_unwrapped | detection`. - **Behavior:** returns the worker's terminal text so the primary can peek at a *detached* worker's progress. Adapter over `AgentControl.read`. ### Control (future) #### `bridge_cancel` *(primary)* - **Params:** `target`. Interrupt the worker's current turn / abandon the rendezvous. No backing code yet. --- ## 6. Rendezvous flows ### 6.1 Delegation β€” happy path One blocking call, zero polls. ```mermaid sequenceDiagram participant P as Primary (Opus) participant B as bridged (MCP + Injector) participant H as herdr participant W as Worker (Claude) P->>B: bridge_send("do X", target=w) β€” blocks B->>B: register waiter(w) B->>H: agent.send(w, "do X") (idle window) H-->>W: prompt injected W->>W: works the turn W->>B: bridge_reply("result") B->>B: resolve waiter(w) B-->>P: { outcome:"reply", text:"result" } ``` ### 6.2 Clarification β€” reverse rendezvous (`bridge_ask`) The worker pauses mid-turn to ask; the primary answers; the worker resumes in the same turn. ```mermaid sequenceDiagram participant P as Primary participant B as bridged participant W as Worker P->>B: bridge_send("do X", target=w) β€” blocks B-->>W: "do X" (injected) W->>B: bridge_ask("which config?") β€” worker blocks B-->>P: { outcome:"question", text:"which config?", turn_id } P->>B: bridge_send("config.yaml", target=w, turn_id) β€” blocks again B-->>W: resolve bridge_ask β†’ { answer:"config.yaml" } W->>W: resumes same turn W->>B: bridge_reply("done") B-->>P: { outcome:"reply", text:"done" } ``` ### 6.3 Detached delegation β€” pane injection The primary does not block; the reply arrives later in its idle pane. ```mermaid sequenceDiagram participant P as Primary participant B as bridged participant W as Worker P->>B: bridge_send("do X", target=w, block=false) B-->>P: { outcome:"dispatched", dispatch_id } P->>P: continues its own work W->>B: bridge_reply("result") Note over B: no waiter β†’ detached path B->>B: Injector.enqueue(primary_pane, "result") B-->>P: injected into idle pane (status-gated) ``` ### 6.4 Uncooperative worker β€” turn-done fallback A worker that never calls `bridge_reply` still returns a result: `bridged` reads its terminal tail when the turn completes. ```mermaid sequenceDiagram participant P as Primary participant B as bridged participant W as Worker P->>B: bridge_send("do X", target=w) β€” blocks B-->>W: "do X" (injected) W->>W: works, never calls bridge_reply B->>B: StatusPoller sees agent_status β†’ idle/done B->>B: AgentControl.read(w, "recent") B-->>P: { outcome:"turn_done", text: } ``` --- ## 7. Status gating Delivery only happens in a safe window. This is the state machine the `Injector` already enforces via `AgentStatus.injectable()`; MCP `bridge_send` is simply its producer. ```mermaid stateDiagram-v2 [*] --> IDLE IDLE --> WORKING: message delivered / picks up WORKING --> IDLE: turn done WORKING --> BLOCKED: awaits input BLOCKED --> WORKING: input delivered IDLE --> UNKNOWN: detection glitch BLOCKED --> UNKNOWN: detection glitch UNKNOWN --> IDLE: re-detected note right of IDLE injectable β€” deliver head of FIFO end note note right of BLOCKED injectable β€” deliver head of FIFO end note note right of WORKING NOT injectable β€” counts as pickup end note note right of UNKNOWN NOT injectable, NOT a pickup β€” wait end note ``` At most one message is delivered per turn: after a send the `Injector` waits for a `WORKING` pickup before delivering the next, with a `PICKUP_GRACE_POLLS` fallback for turns faster than the poll interval. A herdr `events.subscribe` stream can later replace the sampling without touching this state machine. --- ## 8. Error model | Condition | `bridge_send` result | Notes | |---|---|---| | Worker replies | `{ outcome:"reply" }` | normal | | Worker asks | `{ outcome:"question", turn_id }` | answer with `bridge_send(turn_id)` | | Turn ends, no reply | `{ outcome:"turn_done" }` | terminal tail as text | | Deadline elapsed | `{ outcome:"timeout" }` | message may still be queued/delivered | | Worker vanished | error `worker_gone` | `Injector.drop` fails the queued future | | Guard breach on spawn | error `subscription_boundary` | REST `403` parity | | Delivery failed at herdr | error, message dropped | poisoned message not left blocking the FIFO | `bridge_reply` from a worker with no open waiter is **not** an error β€” it falls through to detached pane injection (Β§6.3). --- ## 9. Mapping to existing code The MCP face is a thin adapter layer; nearly every capability already exists behind the REST seam. Only the **rendezvous registry** and the **caller-identity resolver** are new. | MCP tool | Existing collaborator | New work | |---|---|---| | `bridge_send` | `Injector.enqueue`, `AgentControl.send` | waiter registry, timeout, outcome mux (CB-104) | | `bridge_reply` / `bridge_ask` | `Injector` (pane injection) | reverse rendezvous, identity resolver | | `bridge_status` | `AgentControl.status`, `Injector.activeTargets` | pending-drain projection | | `bridge_spawn` / `list` / `stop` | `WorkerService.{spawn,list,stop}` | MCP adapter only | | `bridge_read` | `AgentControl.read` | MCP adapter only | Because the REST routes in `BridgedApp` already exercise the collaborators, MCP tools are validated by **parity** against those routes, not by re-testing behavior. --- ## 10. Open decisions 1. **`bridge_ask` direction.** This page defines it as *worker-asks-primary* (a genuine reverse channel, matching the "inject the primary's pane" language). The alternative β€” a synonym for a blocking primaryβ†’worker send β€” is weaker and produces different plumbing. **Recommend worker-asks-primary.** 2. **Detached delivery shape.** A `block:false` param on `bridge_send` (keeps the catalog small) vs. a separate `bridge_dispatch` tool. **Recommend the param.** 3. **Auto-spawn on send.** `bridge_send` provisions a worker per profile when none exists (simplest primary UX) vs. requiring an explicit `bridge_spawn` first. **Recommend auto-spawn, defaulting on.** 4. **Transport & SDK.** Streamable-HTTP/SSE co-located with the REST bind (recommended) vs. stdio. Requires choosing a Java MCP server SDK and adding it to the pom. --- ## 11. Implementation staging - **CB-104** β€” blocking `bridge_send` + rendezvous registry + caller-identity resolver (the producer that finally drives the inert `StatusPoller`). - **CB-1xx** β€” `bridge_reply` / `bridge_ask` reverse rendezvous + detached pane injection. - **CB-1xx** β€” lifecycle + observability adapters (`bridge_spawn/list/stop/status/read`). - **CB-1xx** β€” transport wiring + `claude mcp add` docs; parity tests vs. REST. - **Later** β€” `bridge_cancel`; swap `StatusPoller` for herdr `events.subscribe`.