diff --git a/docs/MCP-Contract.md b/docs/MCP-Contract.md new file mode 100644 index 0000000..4ba5fff --- /dev/null +++ b/docs/MCP-Contract.md @@ -0,0 +1,374 @@ +# MCP Contract β€” `bridged`'s unified gateway + +> **Status:** 🟑 Design (2026-07-14). Greenfield β€” no MCP code exists yet; the pom carries +> only Javalin/Jackson. This page defines the tool surface that CB-104 and its followers +> implement. It supersedes nothing; it fills the "MCP server face" left open by the +> [Architecture](1-Architecture) page. + +`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`.