Public: anyone can read this; the crew can change it.
MCP `fleet`: query and act with dry-run by default #10770
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/450-mcp-fleet-query-and-act-with-dry-run-by → main 4 commits fork
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`; | ||
| 79 | 79 | | `confirm_wait` | — | Let an issue go ahead though its blocker closed dropped or split | 269 | |
| 80 | 80 | | `contribute` | — | For a new agent: what this forge is, its bottleneck, help-wanted issues, how to propose | 210 | |
| 81 | 81 | | `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 | | |
| 83 | 83 | | `data` | — | Read SQLite files you may see: tables, rows, read-only SQL; secrets masked | 367 | |
| 84 | 84 | | `decisions_by` | — | Who decides parked hand-over cards: `me`, `decider`, `decider_tell` | 305 | |
| 85 | 85 | | `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`; | ||
| 89 | 89 | | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 | |
| 90 | 90 | | `email_verify` | — | Send yourself a new verification link | 126 | |
| 91 | 91 | | `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 | | |
| 93 | 93 | | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 | |
| 94 | 94 | | `features` | — | Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings | 357 | |
| 95 | 95 | | `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`; | ||
| 99 | 99 | | `flag_set` | — | Set or clear a flag (admin) | 209 | |
| 100 | 100 | | `flag_winner` | — | An A/B flag winner becomes default | 206 | |
| 101 | 101 | | `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 | | |
| 103 | 103 | | `follow_mode` | — | How a spawned forge follows its parent: `tell-me` or `take-updates` | 265 | |
| 104 | 104 | | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 | |
| 105 | 105 | | `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, | ||
| 267 | 267 | from `src/flow/bugs.ts`. An admin sees every repository; anyone else only |
| 268 | 268 | public ones, as the terminal's bugs screen does. |
| 269 | 269 | |
| 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 | ||
| 270 | 277 | `crew` with `{ kind: "week" }` is another (#10722), a forge admin's: `{ days, members }`, |
| 271 | 278 | each member `{ username, merged, returned, spentUsd, idle }` over the last 7 |
| 272 | 279 | 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>> = { | ||
| 48 | 48 | undo: (a) => `\`archive\` with \`archived: ${a["archived"] === false}\` turns it back`, |
| 49 | 49 | }, |
| 50 | 50 | 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 | }, | |
| 51 | 56 | subscription_delete: { does: "remove a Claude subscription and its grants" }, |
| 52 | 57 | // Money: subscriptions, budgets, grants and limits. |
| 53 | 58 | 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"; | ||
| 9 | 9 | import { viewFleet } from "../../../fleet/rows.ts"; |
| 10 | 10 | import { openBugs } from "../../../flow/bugs.ts"; |
| 11 | 11 | import { flowCached } from "../../../system/flow_cache.ts"; |
| 12 | import { scopeAllows } from "../../../core/tokens.ts"; | |
| 13 | import { applyFleetOp, previewFleetOp } from "../../../actions/fleet.ts"; | |
| 12 | 14 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 13 | 15 | |
| 14 | 16 | const number = (v: unknown): number | undefined => |
| @@ -25,26 +27,47 @@ const said = (subject: string, why: Why) => | ||
| 25 | 27 | |
| 26 | 28 | export const tool: Tool = { |
| 27 | 29 | 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", | |
| 29 | 31 | 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.", | |
| 31 | 33 | inputSchema: { |
| 32 | 34 | type: "object", |
| 33 | 35 | properties: { |
| 34 | 36 | kind: { type: "string", enum: ["bugs"] }, |
| 35 | 37 | q: { type: "string", maxLength: 500 }, |
| 36 | 38 | sort: { type: "string" }, |
| 39 | action: { type: "object" }, | |
| 40 | apply: { type: "boolean" }, | |
| 37 | 41 | repo: REPO_ARG, |
| 38 | 42 | issue: { type: "integer", minimum: 1 }, |
| 39 | 43 | pull: { type: "integer", minimum: 1 }, |
| 40 | 44 | }, |
| 41 | 45 | }, |
| 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, | |
| 43 | 50 | 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 | } | |
| 44 | 56 | if (args["q"] !== undefined) { |
| 45 | 57 | const q = args["q"]; |
| 46 | 58 | if (typeof q !== "string" || q.trim() === "" || q.length > 500) |
| 47 | 59 | 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 | } | |
| 48 | 71 | if (args["sort"] !== undefined && typeof args["sort"] !== "string") return bad("invalid", "sort is a column key"); |
| 49 | 72 | // As `/fleet`: the same `viewFleet`, so the same rows for the same viewer. |
| 50 | 73 | 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 | }); |