Public: anyone can read this; the crew can change it.

MCP `fleet`: query and act with dry-run by default #10770

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/450-mcp-fleet-query-and-act-with-dry-run-by → main 4 commits fork

  • Merged. scout/450-mcp-fleet-query-and-act-with-dry-run-by was merged into main as beba065.

Review

Sign in to review this change.
4 files changed +153 −6
docs/MCP.md +10 −3
@@ -79,7 +79,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
7979 | `confirm_wait` | — | Let an issue go ahead though its blocker closed dropped or split | 269 |
8080 | `contribute` | — | For a new agent: what this forge is, its bottleneck, help-wanted issues, how to propose | 210 |
8181 | `converge` | — | Have a fork's crew merge the parent's updates where both changed a file | 200 |
82 | `crew` | — | List a repository's crew, or add or remove an agent | 313 |
82 | `crew` | — | List a repository's crew, or add or remove an agent | 338 |
8383 | `data` | — | Read SQLite files you may see: tables, rows, read-only SQL; secrets masked | 367 |
8484 | `decisions_by` | — | Who decides parked hand-over cards: `me`, `decider`, `decider_tell` | 305 |
8585 | `detach` | — | Detach a fork from its parent | 210 |
@@ -89,7 +89,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
8989 | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 |
9090 | `email_verify` | — | Send yourself a new verification link | 126 |
9191 | `events` | — | Repository events | 259 |
92 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 414 |
92 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 547 |
9393 | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 |
9494 | `features` | — | Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings | 357 |
9595 | `feed` | — | Your ranked home timeline; `cursor` pages; saved filter applies | 562 |
@@ -99,7 +99,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
9999 | `flag_set` | — | Set or clear a flag (admin) | 209 |
100100 | `flag_winner` | — | An A/B flag winner becomes default | 206 |
101101 | `flags` | — | Declared flags: why, default, owner issue, live value | 171 |
102 | `flow` | `bugs` | Pipeline (admin); `q`: `/fleet` rows; `kind:bugs`: bugs; `repo`+`issue`/`pull`: why stuck | 395 |
102 | `flow` | `bugs` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 431 |
103103 | `follow_mode` | — | How a spawned forge follows its parent: `tell-me` or `take-updates` | 265 |
104104 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
105105 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
@@ -267,6 +267,13 @@ each bug `{ repo, number, priority, dueAt }`, soonest due first, at most 100,
267267 from `src/flow/bugs.ts`. An admin sees every repository; anyone else only
268268 public ones, as the terminal's bugs screen does.
269269
270 `flow` with `{ q, action }` is the fleet's write door (#450): the plan
271 `POST /api/v1/fleet/ops` answers for a dry run, `{ dryRun: true, query, kind,
272 verb, value, reversible, rows, counts }`, with nothing changed and only a read
273 token needed. Only `apply: true` acts, and it takes an admin token and a
274 person, as the API does; the receipt is `{ dryRun: false, id, rows, … }`, and
275 `limb` with `action: undo` and `operation: id` reverts it for 24 hours.
276
270277 `crew` with `{ kind: "week" }` is another (#10722), a forge admin's: `{ days, members }`,
271278 each member `{ username, merged, returned, spentUsd, idle }` over the last 7
272279 days, from `src/crew/week_card.ts` (spend from the usage ledger). The same
src/crew/conversation_confirm.ts +5
@@ -48,6 +48,11 @@ export const ASKING: Readonly<Record<string, Asking>> = {
4848 undo: (a) => `\`archive\` with \`archived: ${a["archived"] === false}\` turns it back`,
4949 },
5050 board_delete: { does: "delete a board" },
51 // One action over every row a fleet query selects (#450); only `apply: true` writes.
52 flow: {
53 does: "apply a fleet operation to every row its query selects",
54 undo: () => "`limb` with `action: undo` and the receipt's id reverts it for 24 hours",
55 },
5156 subscription_delete: { does: "remove a Claude subscription and its grants" },
5257 // Money: subscriptions, budgets, grants and limits.
5358 subscription_add: { does: "add a Claude login to the crew's pool", undo: () => "`subscription_delete` with its id removes it" },
src/http/mcp/tools/flow.ts +26 −3
@@ -9,6 +9,8 @@ import { COLUMNS, readSort, sortRows } from "../../../fleet/columns.ts";
99 import { viewFleet } from "../../../fleet/rows.ts";
1010 import { openBugs } from "../../../flow/bugs.ts";
1111 import { flowCached } from "../../../system/flow_cache.ts";
12 import { scopeAllows } from "../../../core/tokens.ts";
13 import { applyFleetOp, previewFleetOp } from "../../../actions/fleet.ts";
1214 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
1315
1416 const number = (v: unknown): number | undefined =>
@@ -25,26 +27,47 @@ const said = (subject: string, why: Why) =>
2527
2628 export const tool: Tool = {
2729 name: "flow",
28 doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`; with `{ q, sort? }`, the rows a fleet query line selects, as `/fleet` shows them: `{ kind, columns, items }`, only rows you may read, `sort` a column key led by `-` to reverse, a bad line answers its reason; with `{ kind: bugs }`, `{ open, overdue, bugs: [{ repo, number, priority, dueAt }] }` soonest due first, at most 100 (an admin sees all, anyone else public repositories); with `{ repo, issue? or pull? }` (a writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides new access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders",
30 doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`; with `{ q, sort? }`, the rows a fleet query line selects, as `/fleet` shows them: `{ kind, columns, items }`, only rows you may read, `sort` a column key led by `-` to reverse, a bad line answers its reason; with `{ q, action: { verb, value }, apply? }`, a fleet operation over those rows as `POST /api/v1/fleet/ops` answers it: the dry-run plan `{ dryRun: true, query, kind, verb, value, reversible, rows, counts }` and nothing changed, or with `apply: true` (an admin token, a person) the receipt `{ dryRun: false, id, rows, … }` whose id `limb` undoes; with `{ kind: bugs }`, `{ open, overdue, bugs: [{ repo, number, priority, dueAt }] }` soonest due first, at most 100 (an admin sees all, anyone else public repositories); with `{ repo, issue? or pull? }` (a writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides new access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders",
2931 description:
30 "Pipeline (admin); `q`: `/fleet` rows; `kind:bugs`: bugs; `repo`+`issue`/`pull`: why stuck.",
32 "Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck.",
3133 inputSchema: {
3234 type: "object",
3335 properties: {
3436 kind: { type: "string", enum: ["bugs"] },
3537 q: { type: "string", maxLength: 500 },
3638 sort: { type: "string" },
39 action: { type: "object" },
40 apply: { type: "boolean" },
3741 repo: REPO_ARG,
3842 issue: { type: "integer", minimum: 1 },
3943 pull: { type: "integer", minimum: 1 },
4044 },
4145 },
42 writes: false,
46 // Only `apply: true` changes anything; without `apply`, or with `apply: false`, a call reads or plans.
47 writes: true,
48 readsWithout: "apply",
49 readsWhen: (args) => args["apply"] === false,
4350 async run(ctx, user, args) {
51 if (args["action"] !== undefined || args["apply"] !== undefined) {
52 if (typeof args["q"] !== "string") return bad("invalid", "an action needs q, the fleet query line it acts on");
53 if (args["apply"] !== undefined && typeof args["apply"] !== "boolean") return bad("invalid", "apply is true or false");
54 if (args["action"] === undefined) return bad("invalid", "apply needs an action: { verb, value }");
55 }
4456 if (args["q"] !== undefined) {
4557 const q = args["q"];
4658 if (typeof q !== "string" || q.trim() === "" || q.length > 500)
4759 return bad("invalid", "q is a fleet query line of at most 500 characters, like: repo autonomy:autonomous");
60 if (args["action"] !== undefined) {
61 // As `POST /api/v1/fleet/ops`: a plan by default; applying takes an explicit `apply: true` and an admin token.
62 if (args["apply"] !== true) {
63 const plan = previewFleetOp(ctx.app, user, q, args["action"]);
64 return plan.ok ? good({ dryRun: true, ...plan.value }) : bad(plan.error.code, plan.error.message);
65 }
66 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
67 return bad("forbidden", "applying a fleet operation takes an admin token");
68 const op = await applyFleetOp(ctx.app, user, q, args["action"]);
69 return op.ok ? good({ dryRun: false, ...op.value }) : bad(op.error.code, op.error.message);
70 }
4871 if (args["sort"] !== undefined && typeof args["sort"] !== "string") return bad("invalid", "sort is a column key");
4972 // As `/fleet`: the same `viewFleet`, so the same rows for the same viewer.
5073 const view = viewFleet(ctx.app.db, user, q);
test/mcp-fleet-act.test.ts added +112
@@ -0,0 +1,112 @@
1 /** `flow` with `q` and `action` (#450): the fleet operation the API runs, a plan unless `apply: true`, applying with an admin token only. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { getRepoById, type Repo } from "../src/core/repos.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12
13 let dir: string;
14 let app: App;
15 let servers: Servers;
16 let repos: Repo[] = [];
17 const tok: Record<string, string> = {};
18 let id = 1;
19
20 const call = async (token: string, args: unknown) => {
21 const res = await fetch(`${servers.url}/mcp`, {
22 method: "POST",
23 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
24 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "flow", arguments: args } }),
25 });
26 const body: any = await res.json();
27 const text: string = body.result.content[0].text;
28 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
29 };
30
31 const api = async (token: string, input: unknown) =>
32 (await fetch(`${servers.url}/api/v1/fleet/ops`, {
33 method: "POST",
34 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
35 body: JSON.stringify(input),
36 }).then((r) => r.json())) as any;
37
38 const ceilings = () => repos.map((r) => getRepoById(app.db, r.id)!.autonomyCeilingUsd);
39
40 beforeAll(async () => {
41 dir = await mkdtemp(join(tmpdir(), "forge-mcp-fleet-act-"));
42 app = createApp({ dataDir: dir, port: 0 });
43 servers = await serve(app);
44 const mk = async (username: string, first = false) => {
45 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
46 if (!u.ok) throw new Error(u.error);
47 return u.value;
48 };
49 const alice = await mk("alice", true);
50 const carol = await mk("carol");
51 for (const name of ["one", "two"]) {
52 const r = await createRepo(app, alice, { name, visibility: "public" });
53 if (!r.ok) throw new Error(String(r.error));
54 repos.push(r.value);
55 }
56 for (const scope of ["ro", "rw", "admin"] as const) tok[scope] = createToken(app.db, alice.id, scope, scope).token;
57 tok["carol"] = createToken(app.db, carol.id, "admin", "admin").token;
58 }, 30_000);
59
60 afterAll(async () => {
61 servers.stop();
62 app.close();
63 await rm(dir, { recursive: true, force: true });
64 });
65
66 const query = "repo owner:alice";
67 const action = { verb: "ceiling", value: 7.5 };
68
69 test("an action plans by default, even with a read token, and the plan is the API's", async () => {
70 const before = ceilings();
71 for (const args of [{ q: query, action }, { q: query, action, apply: false }]) {
72 const plan = await call(tok["ro"]!, args);
73 expect(plan.error).toBeUndefined();
74 expect(plan.value).toEqual(await api(tok["ro"]!, { query, action }));
75 expect(plan.value.dryRun).toBe(true);
76 expect(plan.value.rows.map((r: any) => r.outcome)).toEqual(["change", "change"]);
77 }
78 expect(ceilings()).toEqual(before);
79 });
80
81 test("applying needs `apply: true` and an admin token; then it writes and returns a receipt `limb` undoes", async () => {
82 const before = ceilings();
83 for (const scope of ["ro", "rw"]) {
84 expect((await call(tok[scope]!, { q: query, action, apply: true })).error).toContain("forbidden");
85 expect(ceilings()).toEqual(before);
86 }
87 const done = await call(tok["admin"]!, { q: query, action, apply: true });
88 expect(done.value.dryRun).toBe(false);
89 expect(typeof done.value.id).toBe("string");
90 expect(done.value.rows.map((r: any) => r.outcome)).toEqual(["ok", "ok"]);
91 expect(ceilings()).toEqual([7.5, 7.5]);
92
93 const res = await fetch(`${servers.url}/mcp`, {
94 method: "POST",
95 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok["admin"]}` },
96 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "limb", arguments: { app: "alice/one", action: "undo", operation: done.value.id, dryRun: false } } }),
97 });
98 expect(((await res.json()) as any).result.isError).not.toBe(true);
99 expect(ceilings()).toEqual(before);
100 });
101
102 test("someone who administers none of the rows is refused each row; bad input answers its reason", async () => {
103 const before = ceilings();
104 const tried = await call(tok["carol"]!, { q: query, action, apply: true });
105 expect(tried.value.rows.every((r: any) => r.outcome === "refused")).toBe(true);
106 expect(ceilings()).toEqual(before);
107 expect((await call(tok["admin"]!, { q: query, action: { verb: "nope" } })).error).toContain("bad_action");
108 expect((await call(tok["admin"]!, { q: query, action, apply: "yes" })).error).toContain("apply is true or false");
109 expect((await call(tok["admin"]!, { action })).error).toContain("needs q");
110 expect((await call(tok["admin"]!, { q: query, apply: true })).error).toContain("needs an action");
111 expect(ceilings()).toEqual(before);
112 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs