1. opencode.json — mount key bridged -> fleetd. Unit C could not do this: the file is neutralized by the worktree overlay, so every worker sees a stub and correctly reported it held no mount key. Tracked as CB-628. 2. e2e/bridge_ask_transcript.md keeps its name. It is a dated record of a run on 2026-07-16 that really did call bridge_ask, and its first line says so. The harness now writes fleet_ask_transcript.md for new runs; its header already says 'Live fleet_ask', so the two now agree. 3. docs/MCP-Contract.md line 20 — the historical banner describes section 6, and section 6 now says fleet_ask.
17 KiB
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/blockwhere the code takescontent/sessionId/timeoutMs/wait;textwherebridge_replytakescontent;targetwherebridge_stoptakespaneId.- REST paths are wrong: it says
POST /workersandDELETE /workers/{paneId}; the daemon servesPOST /membersandDELETE /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.mdas the short form. Both were checked againstmcp/BridgeMcp.javaon 2026-08-17 and are accurate.What is still worth reading here is §6 — the flows and the error model (rendezvous,
fleet_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.
- One server, both roles. The primary and all workers mount an identical server. The
catalog must serve both, and
bridgedmust decide who is calling from the connection — never from a caller-supplied argument that could be spoofed. - 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 bySubscriptionGuard. - Blocking rendezvous, no busy-poll. The primary consumes a worker's reply through a
single MCP call that
bridgedholds open — never a cross-turn poll loop that would burn subscription quota. - Status-gated delivery. Anything that puts text into a worker flows through the existing
Injector: delivered only when the worker isidle/blocked, at most one message per turn. bridgedowns policy; herdr owns PTYs. MCP tools express intent;bridgedtranslates it into guard checks, rendezvous bookkeeping, and herdragent.*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.
flowchart LR
OPUS["Opus — primary<br/>(Claude Code, env CLEAN)<br/>MCP client"]
subgraph BD["bridged — standalone daemon"]
MCP["MCP server (north face)<br/>bridge_send · bridge_reply<br/>bridge_ask · bridge_status · lifecycle"]
RDV["rendezvous registry<br/>(blocking-call waiters)"]
INJ["Injector + StatusPoller<br/>(status-gated writer)"]
SOCK["herdr socket client (south face)"]
MCP --> RDV
RDV --> INJ
INJ --> SOCK
MCP --> SOCK
end
HERDR["herdr<br/>panes · agent-status"]
W["worker claude pane<br/>ANTHROPIC_BASE_URL set<br/>MCP client"]
OPUS -->|"bridge_send (blocks)"| MCP
W -.->|"bridge_reply / bridge_ask"| MCP
SOCK -->|"agent.start · agent.send<br/>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.
bridgedspawns every worker (WorkerService) and records its herdr session UUID /terminal_idon 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, aterminal_id, or a friendlyprofilename. - Turn correlation. A blocking
bridge_sendregisters a waiter keyed by worker identity. A worker's laterbridge_reply/bridge_askon the same identity resolves that waiter. Aturn_idis 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:
# 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.
5. Tool catalog
| Tool | Caller | Blocks? | Backing (exists today?) |
|---|---|---|---|
bridge_send |
primary | yes (default) | Injector.enqueue ✅ · rendezvous registry ❌ (CB-104) |
bridge_reply |
worker | no | rendezvous ❌ · pane injection via Injector ✅ |
bridge_ask |
worker | yes | reverse rendezvous ❌ |
bridge_status |
either | no | AgentControl.status ✅ · Injector.activeTargets ✅ |
bridge_spawn |
primary | no | WorkerService.spawn ✅ (POST /workers) |
bridge_list |
either | no | WorkerService.list ✅ (/agents) |
bridge_stop |
primary | no | WorkerService.stop ✅ (DELETE /workers/{paneId}) |
bridge_read |
primary | no | AgentControl.read ✅ |
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(defaulttrue);auto_spawn(defaulttrue);turn_id(optional — supplied when answering a worker'sbridge_ask). - Blocking (
block:true): enqueuemessagevia theInjector, 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_statusreaches done/idle with no reply →{ outcome:"turn_done", text:<terminal tail> } - deadline elapses →
{ outcome:"timeout" } - worker gone → error
worker_gone
- worker calls
- 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 viabridge_statuson a split-host primary.
bridge_reply
(worker → primary)
- Params:
text(required);final(defaulttrue). - Behavior: resolve the primary waiter registered against this worker with
text. If no waiter exists (detached delegation),bridgedinjects the primary's idle pane instead. Returns{ delivered:true, mode:"resolved"|"injected" }. Notarget— 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_sendwithoutcome:"question", or injecting its pane). When the primary answers — abridge_sendcarrying the matchingturn_id— that unblocks this call and returns{ answer }to the worker, which continues in the same turn.
Worker lifecycle
Thin adapters over WorkerService — parity with the existing REST routes.
bridge_spawn—{ profile? }→ worker view (sessionId,terminalId,paneId,status). Guard-checked; a boundary breach returns errorsubscription_boundary(the REST403).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'sStop-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.
sequenceDiagram
participant P as Primary (Opus)
participant B as bridged (MCP + Injector)
participant H as herdr
participant W as Worker (Claude)
P->>B: fleet_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: fleet_reply("result")
B->>B: resolve waiter(w)
B-->>P: { outcome:"reply", text:"result" }
6.2 Clarification — reverse rendezvous (fleet_ask)
The worker pauses mid-turn to ask; the primary answers; the worker resumes in the same turn.
sequenceDiagram
participant P as Primary
participant B as bridged
participant W as Worker
P->>B: fleet_send("do X", target=w) — blocks
B-->>W: "do X" (injected)
W->>B: fleet_ask("which config?") — worker blocks
B-->>P: { outcome:"question", text:"which config?", turn_id }
P->>B: fleet_send("config.yaml", target=w, turn_id) — blocks again
B-->>W: resolve fleet_ask → { answer:"config.yaml" }
W->>W: resumes same turn
W->>B: fleet_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.
sequenceDiagram
participant P as Primary
participant B as bridged
participant W as Worker
P->>B: fleet_send("do X", target=w, block=false)
B-->>P: { outcome:"dispatched", dispatch_id }
P->>P: continues its own work
W->>B: fleet_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 fleet_reply still returns a result: bridged reads its terminal
tail when the turn completes.
sequenceDiagram
participant P as Primary
participant B as bridged
participant W as Worker
P->>B: fleet_send("do X", target=w) — blocks
B-->>W: "do X" (injected)
W->>W: works, never calls fleet_reply
B->>B: StatusPoller sees agent_status → idle/done
B->>B: AgentControl.read(w, "recent")
B-->>P: { outcome:"turn_done", text:<terminal tail> }
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.
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
bridge_askdirection. 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.- Detached delivery shape. A
block:falseparam onbridge_send(keeps the catalog small) vs. a separatebridge_dispatchtool. Recommend the param. - Auto-spawn on send.
bridge_sendprovisions a worker per profile when none exists (simplest primary UX) vs. requiring an explicitbridge_spawnfirst. Recommend auto-spawn, defaulting on. - 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 inertStatusPoller). - CB-1xx —
bridge_reply/bridge_askreverse rendezvous + detached pane injection. - CB-1xx — lifecycle + observability adapters (
bridge_spawn/list/stop/status/read). - CB-1xx — transport wiring +
claude mcp adddocs; parity tests vs. REST. - Later —
bridge_cancel; swapStatusPollerfor herdrevents.subscribe.