diff --git a/CLAUDE.md b/CLAUDE.md index f94a0a0..00425f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -215,11 +215,14 @@ must obey belongs in the charter, not here. reads the list with `git config --worktree --get-all fleet.neutralizedConfig`, and the consequence with `git config --worktree --get fleet.neutralizedConfigNote`. Never brief a worker to edit one of these files: the edit cannot be committed, and it will not tell you so. -- **Flows and the error model** — rendezvous, `fleet_ask`, detached delivery, turn-done fallback — - are diagrammed in `docs/MCP-Contract.md` **§6 only**. The rest of that page is a pre-build design - doc whose tool names, parameter names and REST paths never caught up with the code, so do not use - it as the tool reference (CB-609). Section 6 is kept out of this file because this file loads into - every session's context. +- **Flows and the error model** — rendezvous, `fleet_ask`, detached delivery, the turn-done + fallback and status gating — are diagrammed in `docs/MCP-Contract.md`. That page is now flows + only: its pre-build tool catalogue, parameter tables and REST paths were deleted rather than + corrected, because a hand-maintained second copy of the tool surface is what drifted for a month + while this line pointed every session at it (CB-609 / #114). **The live MCP schema is the tool + reference**, with the intent→tool table above as the short form. `McpContractDocTest` fails if + that page names a `fleet_*` tool the server does not register. The flows are kept out of this + file because this file loads into every session's context. ### Redeploying the daemon — the lead may do this (primary only) diff --git a/docs/MCP-Contract.md b/docs/MCP-Contract.md index 5281764..dd21413 100644 --- a/docs/MCP-Contract.md +++ b/docs/MCP-Contract.md @@ -1,311 +1,162 @@ -# MCP Contract — `fleetd`'s unified gateway +# MCP flows and error model — `fleetd` -> **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: +> **What this page is.** The **flows**: how a delegation, a clarification, a detached task and a +> silent member each travel through `fleetd`. These shapes are what shipped, and they are hard to +> read off the code because they span the MCP face, the rendezvous registry, the `Injector` and +> herdr. > -> - **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}`. +> **What this page is NOT: a tool reference.** It deliberately holds no tool catalogue, no +> parameter tables and no REST paths. **The live MCP schema is the authority** — each tool's own +> description and parameters, as mounted — with the intent→tool table in `CLAUDE.md` as the short +> form. > -> **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. +> That absence is the fix for fleetd #114 (CB-609), and it is worth stating why. This page used to +> carry a full tool catalogue written in July 2026, before any MCP code existed. The code shipped; +> the page did not follow. By August it named two tools that do not exist, omitted five that do, +> had the wrong name for nearly every parameter, pointed at REST paths the daemon does not serve, +> and — worst — still described an identity model (*"any connection that does not map to a known +> worker is treated as a primary"*) that was a real privilege bug, fixed since by the ancestry +> walk in fleetd #161. Every one of those errors is the same error: **a second, hand-maintained +> copy of something the code already states**. So the second copy is gone rather than corrected. +> Only the flows remain, because a flow is a shape rather than a name, and shapes are what this +> page was ever good for. > -> 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. +> The names that do appear below are checked by `McpContractDocTest`, which fails if this page +> names a `fleet_*` tool the server does not register. That test is the whole reason it is safe to +> write a tool name here at all. --- -## 1. Design constraints (non-negotiable) +## 1. Rendezvous flows -These come from the project's core invariants and bound every decision below. +### 1.1 Delegation — happy path -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
(Claude Code, env CLEAN)
MCP client"] - subgraph BD["fleetd — standalone daemon"] - MCP["MCP server (north face)
fleet_send · fleet_reply
fleet_ask · fleet_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 -->|"fleet_send (blocks)"| MCP - W -.->|"fleet_reply / fleet_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, `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: }` - - 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`](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. +One blocking call, zero polls. The lead's call is held open by `fleetd` until the member answers. ```mermaid sequenceDiagram - participant P as Primary (Opus) - participant B as fleetd (MCP + Injector) + participant P as "Lead (primary)" + participant B as "fleetd (MCP + Injector)" participant H as herdr - participant W as Worker (Claude) + participant W as "Member" - 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" } + P->>B: "fleet_send{sessionId, content} — blocks" + B->>B: "register waiter(sessionId)" + B->>H: "agent.send — only in an injectable window" + H-->>W: "prompt injected" + W->>W: "works the turn" + W->>B: "fleet_reply{content}" + B->>B: "resolve waiter" + B-->>P: "{ outcome: reply }" ``` -### 6.2 Clarification — reverse rendezvous (`fleet_ask`) +**The cap that matters:** a blocking `fleet_send` is bounded by the *caller's own* MCP client +timeout, about 60 seconds — not by the task. Anything slower than that must use the detached flow +in §1.3, or the lead's call returns while the member is still working. -The worker pauses mid-turn to ask; the primary answers; the worker resumes in the same turn. +### 1.2 Clarification — reverse rendezvous + +The member pauses mid-turn to ask, the lead answers, and the member resumes **the same turn** with +its context intact. ```mermaid sequenceDiagram - participant P as Primary + participant P as "Lead" participant B as fleetd - participant W as Worker + participant W as "Member" - 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" } + P->>B: "fleet_send{sessionId, content} — blocks" + B-->>W: "content injected" + W->>B: "fleet_ask{question} — member blocks" + B-->>P: "{ outcome: question, turnId }" + P->>B: "fleet_send{turnId, content} — answers THIS turn" + B-->>W: "fleet_ask returns the answer" + W->>W: "resumes the same turn" + W->>B: "fleet_reply{content}" + B-->>P: "{ outcome: reply }" ``` -### 6.3 Detached delegation — pane injection +**Answer with `turnId`, never `sessionId`.** A `sessionId` send starts a new turn; it does not +resolve the waiting `fleet_ask`. -The primary does not block; the reply arrives later in its idle pane. +**The window is about 55 seconds and no nudge extends it.** So never brief a member to "ask me": +decide before delegating, or give the member an explicit default to fall back on. + +### 1.3 Detached delegation — the lead does not block + +The lead gets a ticket immediately and collects the answer later. This is the flow for any real +task, because of the ~60s cap in §1.1. ```mermaid sequenceDiagram - participant P as Primary + participant P as "Lead" participant B as fleetd - participant W as Worker + participant W as "Member" - 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) + P->>B: "fleet_send{sessionId, content, wait:false}" + B-->>P: "accepted — ticket" + P->>P: "continues its own work" + W->>B: "fleet_reply{content}" + Note over B: "no waiter is blocked — the reply is held" + B->>B: "nudge the lead's own pane (status-gated)" + P->>B: "fleet_poll{ticket}" + B-->>P: "the member's report" + P->>B: "fleet_ack{target, msgId}" ``` -### 6.4 Uncooperative worker — turn-done fallback +A terminal ticket nudges the lead's pane by itself, so a detached task does not need watching. The +nudge needs an injectable lead pane and is capped, so it is a convenience rather than a guarantee. -A worker that never calls `fleet_reply` still returns a result: `fleetd` reads its terminal -tail when the turn completes. +### 1.4 The member never replies — turn-done fallback + +A member that ends its turn without `fleet_reply` still produces something: `fleetd` reads its +pane tail. This is a **fallback, not a channel** — it is lossy in three separate ways, and every +one of them has produced a wrong answer in practice. ```mermaid sequenceDiagram - participant P as Primary + participant P as "Lead" participant B as fleetd - participant W as Worker + participant W as "Member" - 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: } + P->>B: "fleet_send — blocks or detaches" + B-->>W: "content injected" + W->>W: "works, never calls fleet_reply" + B->>B: "StatusPoller sees the turn end" + B->>B: "read the pane tail" + B->>B: "classify: exhausted? echoed brief? real report?" + B-->>P: "{ outcome: turn_done } or a named failure" ``` +The three ways it goes wrong, and what each looks like now: + +| What happened | What the lead used to get | What it gets today | +|---|---|---| +| The report is longer than the scrape window | The **end** silently cut off | Still clipped, but marked partial | +| The member never started — spent credential | The lead's **own brief** echoed back as a report | A named failure: backend exhausted | +| The member is simply slow | A tail of work in progress | Unchanged — read it as a hint, not a result | + +The echoed-brief case is the one to remember: it reads as a long, on-topic report with nothing in +it from the member. It is suppressed now, but the general rule stands — **check the member's +worktree with `git log` before believing a report you did not watch arrive.** + --- -## 7. Status gating +## 2. 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. +Delivery only happens in a safe window. `fleet_send` is a producer for the `Injector`, which +already enforces this through `AgentStatus.injectable()`. ```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 + IDLE --> WORKING: "message delivered, picked 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 @@ -321,69 +172,17 @@ stateDiagram-v2 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. +**At most one message per turn.** After a send, the `Injector` waits for a `WORKING` pickup before +delivering the next, with a grace-poll fallback for turns that finish faster than the poll +interval. ---- +Two consequences a lead feels directly: -## 8. Error model +- **A second send to a busy member never lands.** It reports as queued and times out. The member + is fine; the message simply waits, and then restarts the member when it next goes idle. +- **A spawned member is not deliverable until it has mounted the MCP.** Until then a send waits on + that gate for about 60 seconds and then fails without ever reaching the pane. -| 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`. +`UNKNOWN` is deliberately neither injectable nor a pickup. A pane whose status cannot be read is +not a pane that is safe to write to — see fleetd #176 for what happens when a gate treats an +unreadable pane as a ready one. diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/McpContractDocTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/McpContractDocTest.java new file mode 100644 index 0000000..fbcf43e --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/McpContractDocTest.java @@ -0,0 +1,121 @@ +package dev.ltms.fleet.mcp; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #114 (CB-609): the guard that lets {@code docs/MCP-Contract.md} name a tool at all. + * + *

That page was written in July 2026, before any MCP code existed, and then did not follow the + * code. By August it named two tools that had never been built, omitted five that shipped, had the + * wrong name for nearly every parameter, and still described a caller-identity rule that was a + * privilege bug by then. Nothing failed, because nothing checked it — and {@code CLAUDE.md} sends + * every session in the fleet to that page. + * + *

The fix was to delete the tool catalogue rather than correct it: a hand-maintained second copy + * of the tool surface is the defect, not the particular errors it had accumulated. What survives is + * the flows, which are shapes rather than names. But the flows still have to say {@code fleet_send} + * somewhere to be readable, and that is exactly the sentence that rots. This test is what makes it + * safe to write. + * + *

It checks source text, not behaviour. It reads the Markdown and reads {@link FleetMcp}'s + * source, and it only catches a name in the doc that the server does not register. It cannot catch a + * flow that describes the wrong order, or a parameter name in prose — those are not name-shaped. The + * doc's own header carries that caveat for its readers. + */ +class McpContractDocTest { + + /** Tests run with the module directory as cwd, so the repo-root doc is one level up. */ + private static final Path DOC = Path.of("../docs/MCP-Contract.md"); + private static final Path MCP_SOURCE = Path.of("src/main/java/dev/ltms/fleet/mcp/FleetMcp.java"); + + private static Set matches(Path file, String regex) throws Exception { + Matcher m = Pattern.compile(regex).matcher(Files.readString(file)); + Set found = new LinkedHashSet<>(); + while (m.find()) { + found.add(m.group(1)); + } + return found; + } + + /** Every {@code fleet_*} the doc mentions, in prose or in a diagram. */ + private static Set toolsNamedInTheDoc() throws Exception { + return matches(DOC, "(fleet_[a-z_]+)"); + } + + /** Every tool {@link FleetMcp} actually registers, read from its {@code tool("…")} calls. */ + private static Set toolsTheServerRegisters() throws Exception { + return matches(MCP_SOURCE, "tool\\(\"(fleet_[a-z_]+)\""); + } + + @Test + @DisplayName("[SOURCE TEXT] every fleet_* tool named in MCP-Contract.md is one the server registers") + void theDocNamesNoToolThatDoesNotExist() throws Exception { + Set registered = toolsTheServerRegisters(); + Set named = toolsNamedInTheDoc(); + + Set unknown = new LinkedHashSet<>(named); + unknown.removeAll(registered); + + assertTrue(unknown.isEmpty(), + "docs/MCP-Contract.md names " + unknown + ", which FleetMcp does not register. " + + "Checked " + named.size() + " name(s) in the doc against " + registered.size() + + " registered tool(s): " + registered + ". This is the fleetd #114 defect " + + "recurring — the doc named fleet_read and fleet_cancel for weeks after the " + + "code shipped without them. Either fix the name or drop it from the page; do " + + "NOT weaken this test."); + } + + /** + * The denominator guard. The check above passes trivially if the doc stops naming any tool at + * all — an empty set is a subset of everything. A checker that can silently check nothing is the + * fleetd #113 shape, so this pins that the doc really is still describing the flows, and that + * the registration scrape really did find the server's tools. + */ + @Test + @DisplayName("[SOURCE TEXT] the doc/server name check is not vacuous — both sides found names") + void theCheckActuallyHasSomethingToCheck() throws Exception { + Set registered = toolsTheServerRegisters(); + Set named = toolsNamedInTheDoc(); + + assertTrue(registered.size() >= 10, + "scraped only " + registered.size() + " tool registrations from FleetMcp (" + registered + + "); the server registers eleven, so the tool(\"…\") scrape has stopped matching " + + "and the check above is now vacuous"); + assertTrue(named.size() >= 4, + "docs/MCP-Contract.md names only " + named.size() + " fleet_* tool(s) (" + named + "). " + + "The flows describe delegation, clarification, detached delivery and the " + + "turn-done fallback, so it should name several. Too few means the page has been " + + "gutted and this test is guarding nothing."); + } + + /** + * fleetd #114's actual lesson. The catalogue was deleted on purpose; a well-meaning "let me just + * document the tools here" restores the exact second copy that drifted for a month. + */ + @Test + @DisplayName("[SOURCE TEXT] MCP-Contract.md still says it is not the tool reference") + void theDocStillDisclaimsBeingTheToolReference() throws Exception { + String doc = Files.readString(DOC); + assertTrue(doc.contains("**What this page is NOT: a tool reference.**"), + "docs/MCP-Contract.md must keep saying it is not the tool reference. That sentence is " + + "the fix for fleetd #114: the page carried a hand-maintained tool catalogue that " + + "drifted from the code for a month while CLAUDE.md pointed every session at it."); + assertEquals(0, countTables(doc.substring(0, doc.indexOf("## 1. Rendezvous flows"))), + "the header of docs/MCP-Contract.md must not grow a tool/parameter table — that is the " + + "second copy fleetd #114 deleted"); + } + + private static int countTables(String markdown) { + return (int) markdown.lines().filter(l -> l.strip().startsWith("|")).count(); + } +}