3ce76a5d69
The alias removal left bridge_* tool names in prose. Fix them: - README no longer claims the old bridge_* names still answer (they were removed). - pom + LeadTabScanner comments name fleet_* tools. - FleetMcp comment no longer mentions the removed deprecated twin. - docs/MCP-Contract.md and e2e swept bridge_* -> fleet_*; e2e ask files renamed. The historical mcp__bridge__* mount-name note in CLAUDE.md is kept on purpose. 949 tests pass.
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:** `fleet_read`, `fleet_cancel`.
|
|
> - **Shipped tools it omits:** `fleet_poll`, `fleet_ack`, `fleet_profiles`, `fleet_whoami`.
|
|
> - **Parameter names are wrong nearly everywhere** — it says `message`/`target`/`timeout_seconds`/
|
|
> `block` where the code takes `content`/`sessionId`/`timeoutMs`/`wait`; `text` where
|
|
> `fleet_reply` takes `content`; `target` where `fleet_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/>fleet_send · fleet_reply<br/>fleet_ask · fleet_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 -->|"fleet_send (blocks)"| MCP
|
|
W -.->|"fleet_reply / fleet_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 `fleet_send` registers a *waiter* keyed by worker
|
|
identity. A worker's later `fleet_reply` / `fleet_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?) |
|
|
|---|---|---|---|
|
|
| [`fleet_send`](#fleet_send) | primary | yes (default) | `Injector.enqueue` ✅ · rendezvous registry ❌ (CB-104) |
|
|
| [`fleet_reply`](#fleet_reply) | worker | no | rendezvous ❌ · pane injection via `Injector` ✅ |
|
|
| [`fleet_ask`](#fleet_ask) | worker | yes | reverse rendezvous ❌ |
|
|
| [`fleet_status`](#fleet_status) | either | no | `AgentControl.status` ✅ · `Injector.activeTargets` ✅ |
|
|
| [`fleet_spawn`](#lifecycle) | primary | no | `WorkerService.spawn` ✅ (`POST /workers`) |
|
|
| [`fleet_list`](#lifecycle) | either | no | `WorkerService.list` ✅ (`/agents`) |
|
|
| [`fleet_stop`](#lifecycle) | primary | no | `WorkerService.stop` ✅ (`DELETE /workers/{paneId}`) |
|
|
| [`fleet_read`](#fleet_read) | primary | no | `AgentControl.read` ✅ |
|
|
| [`fleet_cancel`](#fleet_cancel) | primary | no | — ❌ (future) |
|
|
|
|
### Core: delegation & rendezvous
|
|
|
|
#### `fleet_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 `fleet_ask`).
|
|
- **Blocking (`block:true`):** enqueue `message` via the `Injector`, then hold the call open
|
|
until exactly one of:
|
|
- worker calls `fleet_reply` → `{ outcome:"reply", text }`
|
|
- worker calls `fleet_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 `fleet_status` on a split-host primary.
|
|
|
|
#### `fleet_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.
|
|
|
|
#### `fleet_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 `fleet_send` with `outcome:"question"`, or injecting its pane). When the primary
|
|
answers — a `fleet_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.
|
|
|
|
- **`fleet_spawn`** — `{ profile? }` → worker view (`sessionId`, `terminalId`, `paneId`,
|
|
`status`). Guard-checked; a boundary breach returns error `subscription_boundary` (the
|
|
REST `403`).
|
|
- **`fleet_list`** — no params → all workers + `agent_status`. Read-only, either role.
|
|
- **`fleet_stop`** — `{ target }` → tears down the pane and its dedicated tab. Idempotent.
|
|
|
|
### Observability
|
|
|
|
#### `fleet_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.
|
|
|
|
#### `fleet_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)
|
|
|
|
#### `fleet_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 `fleet_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 | `fleet_send` result | Notes |
|
|
|---|---|---|
|
|
| Worker replies | `{ outcome:"reply" }` | normal |
|
|
| Worker asks | `{ outcome:"question", turn_id }` | answer with `fleet_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 |
|
|
|
|
`fleet_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 |
|
|
|---|---|---|
|
|
| `fleet_send` | `Injector.enqueue`, `AgentControl.send` | waiter registry, timeout, outcome mux (CB-104) |
|
|
| `fleet_reply` / `fleet_ask` | `Injector` (pane injection) | reverse rendezvous, identity resolver |
|
|
| `fleet_status` | `AgentControl.status`, `Injector.activeTargets` | pending-drain projection |
|
|
| `fleet_spawn` / `list` / `stop` | `WorkerService.{spawn,list,stop}` | MCP adapter only |
|
|
| `fleet_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. **`fleet_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 `fleet_send` (keeps the catalog
|
|
small) vs. a separate `fleet_dispatch` tool. **Recommend the param.**
|
|
3. **Auto-spawn on send.** `fleet_send` provisions a worker per profile when none exists
|
|
(simplest primary UX) vs. requiring an explicit `fleet_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 `fleet_send` + rendezvous registry + caller-identity resolver
|
|
(the producer that finally drives the inert `StatusPoller`).
|
|
- **CB-1xx** — `fleet_reply` / `fleet_ask` reverse rendezvous + detached pane injection.
|
|
- **CB-1xx** — lifecycle + observability adapters (`fleet_spawn/list/stop/status/read`).
|
|
- **CB-1xx** — transport wiring + `claude mcp add` docs; parity tests vs. REST.
|
|
- **Later** — `fleet_cancel`; swap `StatusPoller` for herdr `events.subscribe`.
|