Files
fleetd/docs/MCP-Contract.md
Dai Ha 3ce76a5d69
CI / contract (push) Successful in 1m5s
CI / build (push) Failing after 1m35s
CB-634: finish the bridge_* -> fleet_* tool rename in docs and comments
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.
2026-08-25 04:06:43 +02:00

16 KiB

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.
  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: 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.

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) 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:

# 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.


5. Tool catalog

Tool Caller Blocks? Backing (exists today?)
fleet_send primary yes (default) Injector.enqueue ✅ · rendezvous registry ❌ (CB-104)
fleet_reply worker no rendezvous ❌ · pane injection via Injector ✅
fleet_ask worker yes reverse rendezvous ❌
fleet_status either no AgentControl.status ✅ · Injector.activeTargets ✅
fleet_spawn primary no WorkerService.spawn ✅ (POST /workers)
fleet_list either no WorkerService.list ✅ (/agents)
fleet_stop primary no WorkerService.stop ✅ (DELETE /workers/{paneId})
fleet_read primary no AgentControl.read ✅
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

Thin adapters over WorkerService — 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.

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.

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.

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.

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.

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.