ecc590f344
Part of #145 (CB-632). Documentation only, plus one internal literal. Unit 1 renamed the package and classes, which left every doc describing classes that no longer exist. This fixes the prose across README.md, docs/ and bridged/docs/ -- 18 files. Renamed: dev.ltms.bridged -> dev.ltms.fleet, the five class names, and "bridged" where it names the daemon as a product rather than a path. Also renamed two literals, because a doc that disagrees with the code is worse than one that is out of date: - bridged-local-noauth -> fleetd-local-noauth. A placeholder apiKey OpenCodeLauncher sends when a profile resolves no token, to a local endpoint that does not check it. No test asserts the old string. - the vnd.ltms.bridged.* media type in the M4 design doc. It appears in no Java file, so nothing implements it yet. Deliberately NOT renamed, because each is still literally true today and changes only at the cutover: - paths: bridged/, bridged.yaml, bridged.example.yaml, bridged.jar, .bridged-worktrees, deploy/dev.ltms.bridged.plist, scripts/redeploy-bridged.sh, bridged-launchd-wrapper.sh - bridged_* metric names -- renaming these after the monitoring is wired would break dashboard continuity, so they move before it is - bridge_* MCP tool names, which answer alongside fleet_* on purpose - BRIDGED_* environment variables, read by a file outside this repo Method note: perl, not sed. BSD sed has no \b and no lookaround, and a word-boundary expression there fails silently. The prose replace uses (?<![\w./-])bridged(?![\w./-]) so it cannot touch a path or an identifier, then every remaining hit was read by hand. Verified: mvn clean install green, 51 classes, 878 tests, 0 failures.
390 lines
16 KiB
Markdown
390 lines
16 KiB
Markdown
# MCP Contract — `fleetd`'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/FleetMcp.java` on 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**.
|
|
|
|
`fleetd` 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 `fleetd` 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 `fleetd` 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. **`fleetd` owns policy; herdr owns PTYs.** MCP tools express *intent*; `fleetd`
|
|
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<br/>(Claude Code, env CLEAN)<br/>MCP client"]
|
|
subgraph BD["fleetd — 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, `fleetd` resolves the caller's role on every
|
|
request — this is the linchpin of the whole contract and has no code yet.
|
|
|
|
- **Workers are known.** `fleetd` 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
|
|
|
|
`fleetd` 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 fleetd 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:<terminal tail> }`
|
|
- 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), `fleetd` **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
|
|
<a id="lifecycle"></a>
|
|
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 fleetd (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.
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant P as Primary
|
|
participant B as fleetd
|
|
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.
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant P as Primary
|
|
participant B as fleetd
|
|
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: `fleetd` reads its terminal
|
|
tail when the turn completes.
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant P as Primary
|
|
participant B as fleetd
|
|
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.
|
|
|
|
```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 `FleetApp` 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`.
|