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

MCP reads the rows a fleet query line selects #10153

Merged opened this

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

Merged

torque/forge:torque/10132-mcp-reads-the-rows-a-fleet-query-line-se → main 2 commits fork

  • Merged. torque/10132-mcp-reads-the-rows-a-fleet-query-line-se was merged into main as 8a260a5.

Review

Sign in to review this change.
3 files changed +101 −3
docs/features/fleet.md +3 −1
@@ -1,6 +1,6 @@
11 # Fleet rows
22
3 Before a fleet operation acts, see the rows its query line selects. There are two doors, and both show the same rows.
3 Before a fleet operation acts, see the rows its query line selects. There are four doors, and all show the same rows.
44
55 **On the web:** `GET /fleet?q=<query line>&sort=<column>` (`src/web/pages/fleet.ts`), linked from the fleet graph as "Rows".
66
@@ -19,6 +19,8 @@ Before a fleet operation acts, see the rows its query line selects. There are tw
1919 - `sort` is a column key, `-` leading to reverse; an unknown one is `invalid_sort` (422).
2020 - A line that does not parse, an empty `q`, or a `q` over 500 characters is `invalid_query` (422) with the reason.
2121
22 **In MCP:** the `flow` tool with `{ q, sort? }` answers `{ kind, columns, items }`, the same rows `/fleet` shows, for the caller (public repositories only when the token's user cannot read more). A bad line, a bad `sort` or an empty `q` is an error with the reason, so an agent previews a line without applying anything.
23
2224 **The query line:** a kind, then `field:value` terms that must all hold. Both doors show the same grammar under the line: one sentence, then each kind with its fields (`GRAMMAR` and `fieldsOf` in `src/fleet/selection.ts`).
2325
2426 - A value starting with `!` negates its term: `repo:!trevato/forge`.
src/http/mcp/tools/flow.ts +20 −2
@@ -5,6 +5,8 @@ import { canWrite } from "../../../core/repos.ts";
55 import { mayView } from "../../../core/sealed.ts";
66 import { whyIssue, whyPull, liveHead, whyRepo, type Why } from "../../../flow/why.ts";
77 import { countRead } from "../../../system/telemetry_reads.ts";
8 import { COLUMNS, readSort, sortRows } from "../../../fleet/columns.ts";
9 import { viewFleet } from "../../../fleet/rows.ts";
810 import { openBugs } from "../../../flow/bugs.ts";
911 import { flowCached } from "../../../system/flow_cache.ts";
1012 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
@@ -23,13 +25,15 @@ const said = (subject: string, why: Why) =>
2325
2426 export const tool: Tool = {
2527 name: "flow",
26 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 `{ 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",
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",
2729 description:
28 "Pipeline (admin); `kind: \"bugs\"`: open bugs on their clocks; with `repo` and `issue` or `pull`: why it is not moving.",
30 "Pipeline (admin); `q`: `/fleet` rows; `kind:bugs`: bugs; `repo`+`issue`/`pull`: why stuck.",
2931 inputSchema: {
3032 type: "object",
3133 properties: {
3234 kind: { type: "string", enum: ["bugs"] },
35 q: { type: "string", maxLength: 500 },
36 sort: { type: "string" },
3337 repo: REPO_ARG,
3438 issue: { type: "integer", minimum: 1 },
3539 pull: { type: "integer", minimum: 1 },
@@ -37,6 +41,20 @@ export const tool: Tool = {
3741 },
3842 writes: false,
3943 async run(ctx, user, args) {
44 if (args["q"] !== undefined) {
45 const q = args["q"];
46 if (typeof q !== "string" || q.trim() === "" || q.length > 500)
47 return bad("invalid", "q is a fleet query line of at most 500 characters, like: repo autonomy:autonomous");
48 if (args["sort"] !== undefined && typeof args["sort"] !== "string") return bad("invalid", "sort is a column key");
49 // As `/fleet`: the same `viewFleet`, so the same rows for the same viewer.
50 const view = viewFleet(ctx.app.db, user, q);
51 if (!view.ok) return bad("invalid_query", view.error);
52 const asked = args["sort"];
53 const sort = asked === undefined || asked === "" ? undefined : readSort(view.kind, asked);
54 if (asked !== undefined && asked !== "" && sort === undefined)
55 return bad("invalid_sort", "sort is a column of the kind, optionally led by -");
56 return good({ kind: view.kind, columns: COLUMNS[view.kind], items: sortRows(view.rows, sort) });
57 }
4058 if (args["kind"] !== undefined) {
4159 if (args["kind"] !== "bugs") return bad("invalid", "kind is bugs");
4260 // As the terminal's bugs screen: an admin sees every repository, anyone else public ones.
test/mcp-fleet-rows.test.ts added +78
@@ -0,0 +1,78 @@
1 /** `flow` with `q` (#10132): the rows a fleet line selects, the same ones `/fleet` shows, only those the caller may read. */
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 { createSession } from "../src/core/sessions.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 aliceTok: string;
17 let carolTok: string;
18 let carolCookie: string;
19 let id = 1;
20
21 const call = async (token: string, args: unknown) => {
22 const res = await fetch(`${servers.url}/mcp`, {
23 method: "POST",
24 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
25 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "flow", arguments: args } }),
26 });
27 const body: any = await res.json();
28 const text: string = body.result.content[0].text;
29 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
30 };
31
32 beforeAll(async () => {
33 dir = await mkdtemp(join(tmpdir(), "forge-mcp-fleet-rows-"));
34 app = createApp({ dataDir: dir, port: 0 });
35 servers = await serve(app);
36 const mk = async (username: string, first = false) => {
37 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
38 if (!u.ok) throw new Error(u.error);
39 return u.value;
40 };
41 const alice = await mk("alice", true);
42 const carol = await mk("carol");
43 aliceTok = createToken(app.db, alice.id, "rw", "rw").token;
44 carolTok = createToken(app.db, carol.id, "rw", "rw").token;
45 carolCookie = `forge_session=${createSession(app.db, carol.id).token}`;
46 for (const [name, visibility] of [["pubrepo", "public"], ["privrepo", "private"]] as const) {
47 const r = await createRepo(app, alice, { name, visibility });
48 if (!r.ok) throw new Error(String(r.error));
49 }
50 }, 30_000);
51
52 afterAll(async () => {
53 servers.stop();
54 app.close();
55 await rm(dir, { recursive: true, force: true });
56 });
57
58 test("MCP and /fleet select the same rows for one line, each for its caller", async () => {
59 const line = "repo owner:alice";
60 const mcp = await call(carolTok, { q: line });
61 expect(mcp.value.kind).toBe("repo");
62 expect(mcp.value.columns.length).toBeGreaterThan(0);
63 const names = mcp.value.items.map((r: any) => r.repo);
64 expect(names).toEqual(["alice/pubrepo"]);
65 const page = await (await fetch(`${servers.url}/fleet?${new URLSearchParams({ q: line })}`, { headers: { cookie: carolCookie } })).text();
66 expect(page).toContain("alice/pubrepo");
67 expect(page).not.toContain("alice/privrepo");
68
69 const admin = await call(aliceTok, { q: line, sort: "-repo" });
70 expect(admin.value.items.map((r: any) => r.repo).sort()).toEqual(["alice/privrepo", "alice/pubrepo"]);
71 });
72
73 test("a bad line answers its reason; a bad sort and an empty q are refused", async () => {
74 const bad = await call(aliceTok, { q: "run cost:abc" });
75 expect(bad.error).toContain("cost wants a number");
76 expect((await call(aliceTok, { q: "repo", sort: "nope" })).error).toContain("sort");
77 expect((await call(aliceTok, { q: " " })).error).toContain("q is");
78 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs