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

MCP `flags` answers what `readFlags` answers, for a forge admin #7285

Merged opened this

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

Merged

scout/forge:scout/7018-mcp-flags-answers-what-readflags-answers → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7018-mcp-flags-answers-what-readflags-answers was merged into main as 2adf588.

Review

Sign in to review this change.
3 files changed +14 −35
docs/MCP.md +1 −1
@@ -78,7 +78,7 @@ for a tunnel and a forge that sleeps.
7878 | `feed_chips` | `{ kind?, value?, hidden? }`: the feed chips you switched off, which `feed` and `asks` leave out; with `kind` (`kind` or `person`), `value` and `hidden` it switches one off or on | yes |
7979 | `feed_dismiss` | `{ item, kind, app? }`: dismiss a feed item or mute its app, as the card's menu does (`kind` dismissed or muted); `feed` then leaves it out; `app` (`owner/name`) names the app for an item that is not an event; answers `{ id }` | yes |
8080 | `flag_set` | `{ name, value?, clear? }`: set a declared flag's live value (`value` is `{ forge?, users?, roles?, percent? }`) or `clear` it to its default, with the Flags page's checks and refusals; a forge admin's (never an agent), an admin token; answers `{ name, value, says }` | yes |
81 | `flags` | no input: every declared flag with why it exists, its default, the issue that owns removing it and its live value (`null` when unset); read-only, a forge admin's and never an agent's; setting stays on `/settings/flags` | |
81 | `flags` | no input: every declared flag with why it exists, its default, the issue that owns removing it and its live `value` (absent when unset), as `readFlags` answers; read-only, a forge admin's and never an agent's; setting is `flag_set` | |
8282 | `flow` | 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` | |
8383 | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes |
8484 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
src/http/mcp/tools/flags.ts +4 −13
@@ -1,23 +1,14 @@
1 import { flagRegistry, mayManageFlags } from "../../../core/flags.ts";
1 import { readFlags } from "../../../actions/flags_view.ts";
22 import { good, bad, type Tool } from "../shared.ts";
33
44 export const tool: Tool = {
55 name: "flags",
6 doc: "no input: every declared flag with why it exists, its default, the issue that owns removing it and its live value (`null` when unset); read-only, a forge admin's and never an agent's; setting stays on `/settings/flags`",
6 doc: "no input: every declared flag with why it exists, its default, the issue that owns removing it and its live `value` (absent when unset), as `readFlags` answers; read-only, a forge admin's and never an agent's; setting is `flag_set`",
77 description: "Declared flags: why, default, owner issue, live value.",
88 inputSchema: { type: "object", properties: {} },
99 writes: false,
1010 run(_ctx, user) {
11 if (!mayManageFlags(user)) return bad("forbidden", "Flags are set by a forge admin.");
12 const flags = flagRegistry()
13 .entries()
14 .map(({ name, decl, value }) => ({
15 name,
16 why: decl.why,
17 default: decl.default,
18 issue: decl.issue,
19 live: value ?? null,
20 }));
21 return good({ flags });
11 const out = readFlags(user);

Good: one shared read and one shared refusal, so the page, the terminal and the MCP can't drift apart.

12 return out.ok ? good({ flags: out.flags }) : bad("forbidden", out.message);
2213 },
2314 };
test/mcp-flags.test.ts +9 −21
@@ -22,16 +22,17 @@ describe("MCP flags", () => {
2222 let app: App;
2323 let servers: Servers;
2424
25 const call = async (token: string) => {
25 const call = async (token: string, name = "flags", args: Record<string, unknown> = {}) => {
2626 const res = await fetch(`${servers.url}/mcp`, {
2727 method: "POST",
2828 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
29 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "flags", arguments: {} } }),
29 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
3030 });
3131 const body = (await res.json()) as any;
3232 return { error: body.result.isError === true, text: body.result.content[0].text as string };
3333 };
34 const tokenOf = (name: string) => createToken(app.db, getUserByName(app.db, name)!.id, "t", "ro").token;
34 const tokenOf = (name: string, scope: "ro" | "admin" = "ro") =>
35 createToken(app.db, getUserByName(app.db, name)!.id, "t", scope).token;
3536
3637 beforeAll(async () => {
3738 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-flags-"));
@@ -56,28 +57,15 @@ describe("MCP flags", () => {
5657 const out = await call(tokenOf("alice"));
5758 expect(out.error).toBe(false);
5859 expect(JSON.parse(out.text).flags).toEqual([
59 { name: "alpha", why: "shows the new board", default: false, issue: 5417, live: null },
60 { name: "alpha", why: "shows the new board", default: false, issue: 5417 },
6061 ]);
6162 });
6263
63 test("after it is set on the page, the live value changes", async () => {
64 const cookie = (
65 await fetch(`${servers.url}/signin`, {
66 method: "POST",
67 redirect: "manual",
68 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
69 body: new URLSearchParams({ username: "alice", password: PASS }),
70 })
71 ).headers.get("set-cookie")!.split(";")[0]!;
72 const set = await fetch(`${servers.url}/settings/flags`, {
73 method: "POST",
74 redirect: "manual",
75 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
76 body: new URLSearchParams({ name: "alpha", forge: "on", percent: "30" }),
77 });
78 expect(set.status).toBe(303);
64 test("flag_set changes the live value", async () => {
65 const set = await call(tokenOf("alice", "admin"), "flag_set", { name: "alpha", value: { forge: true, percent: 30 } });
66 expect(set.error).toBe(false);
7967 const [flag] = JSON.parse((await call(tokenOf("alice"))).text).flags;
80 expect(flag.live).toEqual({ forge: true, percent: 30 });
68 expect(flag.value).toEqual({ forge: true, percent: 30 });
8169 expect(flag.default).toBe(false);
8270 });
8371

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs