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

MCP `flags`: every declared flag, why it exists, its default and its live value, for a forge admin #6991

Merged opened this

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

Merged

scout/forge:scout/5417-mcp-flags-every-declared-flag-why-it-exi → main 8 commits fork 1 thread · 1 unresolved

  • Merged. scout/5417-mcp-flags-every-declared-flag-why-it-exi was merged into main as 72a25b2.

Review

Sign in to review this change.
6 files changed +129 −0
docs/MCP.md +1
@@ -77,6 +77,7 @@ for a tunnel and a forge that sleeps.
7777 | `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 |
7878 | `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 |
7979 | `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 |
80 | `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` | |
8081 | `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` | |
8182 | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes |
8283 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
src/core/flags.ts +8
@@ -216,6 +216,14 @@ export function makeFlags<const D extends Record<string, FlagDecl>>(decls: D) {
216216 value(name: keyof D & string): FlagValue | undefined {
217217 return values.get(name);
218218 },
219 /** Every declared flag with its declaration and live value, in declaration order. */
220 entries(): { name: string; decl: FlagDecl; value: FlagValue | undefined }[] {
221 return Object.keys(decls).map((name) => ({
222 name,
223 decl: decls[name]!,
224 value: values.get(name),
225 }));
226 },
219227 /**
220228 * Store `value` for a declared flag (`undefined` clears it), keep the
221229 * change in `forge_setting_change`, and reload so it is live at once.
src/http/mcp/tools/flags.ts added +23
@@ -0,0 +1,23 @@
1 import { flagRegistry, mayManageFlags } from "../../../core/flags.ts";
2 import { good, bad, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 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`",
7 description: "Declared flags: why, default, owner issue, live value.",
8 inputSchema: { type: "object", properties: {} },
9 writes: false,
10 run(_ctx, user) {
11 if (!mayManageFlags(user)) return bad("forbidden", "Flags are set by a forge admin.");

Nit: use FLAGS_ADMIN_ONLY from core/flags.ts instead of repeating the string. Its doc says it is the one wording the action, the page and the terminal share. flag_set repeats the literal too, so both could move to the constant in a later pull.

Nit: use FLAGS_ADMIN_ONLY from core/flags.ts instead of repeating the literal. That constant exists so the action, the page and the terminal refuse in the same words. flag_set repeats the literal too, so both can be fixed together later.

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 });
22 },
23 };
src/http/mcp/tools/index.ts +1
@@ -50,6 +50,7 @@ export { tool as feed } from "./feed.ts";
5050 export { tool as feed_chips } from "./feed_chips.ts";
5151 export { tool as feed_dismiss } from "./feed_dismiss.ts";
5252 export { tool as flag_set } from "./flag_set.ts";
53 export { tool as flags } from "./flags.ts";
5354 export { tool as flow } from "./flow.ts";
5455 export { tool as follow_mode } from "./follow_mode.ts";
5556 export { tool as following } from "./following.ts";
test/mcp-flags.test.ts added +95
@@ -0,0 +1,95 @@
1 /** MCP `flags`: the flags page's registry, read from a harness by a forge admin. */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { makeFlags } from "../src/core/flags.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser, getUserByName } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { useFlagRegistry } from "../src/web/pages/flags.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 const PASS = "a-long-flags-passphrase";
18 const reg = makeFlags({ alpha: { why: "shows the new board", default: false, issue: 5417 } });
19
20 describe("MCP flags", () => {
21 let dataDir = "";
22 let app: App;
23 let servers: Servers;
24
25 const call = async (token: string) => {
26 const res = await fetch(`${servers.url}/mcp`, {
27 method: "POST",
28 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: {} } }),
30 });
31 const body = (await res.json()) as any;
32 return { error: body.result.isError === true, text: body.result.content[0].text as string };
33 };
34 const tokenOf = (name: string) => createToken(app.db, getUserByName(app.db, name)!.id, "t", "ro").token;
35
36 beforeAll(async () => {
37 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-flags-"));
38 app = createApp({ dataDir, port: 0 });
39 servers = await serve(app);
40 for (const [username, isAdmin] of [["alice", true], ["bob", false], ["pixel", false]] as const) {
41 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: PASS, isAdmin });
42 if (!made.ok) throw new Error(username);
43 }
44 app.db.query("UPDATE user SET is_agent = 1, is_admin = 1 WHERE username = 'pixel'").run();
45 useFlagRegistry(reg);
46 }, 30_000);
47
48 afterAll(async () => {
49 useFlagRegistry(undefined);
50 servers.stop();
51 app.close();
52 await rm(dataDir, { recursive: true, force: true });
53 });
54
55 test("an admin sees a declared flag with its default", async () => {
56 const out = await call(tokenOf("alice"));
57 expect(out.error).toBe(false);
58 expect(JSON.parse(out.text).flags).toEqual([
59 { name: "alpha", why: "shows the new board", default: false, issue: 5417, live: null },
60 ]);
61 });
62
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);
79 const [flag] = JSON.parse((await call(tokenOf("alice"))).text).flags;
80 expect(flag.live).toEqual({ forge: true, percent: 30 });
81 expect(flag.default).toBe(false);
82 });
83
84 test("a non-admin is refused in the page's words", async () => {
85 const out = await call(tokenOf("bob"));
86 expect(out.error).toBe(true);
87 expect(out.text).toContain("Flags are set by a forge admin.");
88 });
89
90 test("an agent is refused too, even flagged admin", async () => {
91 const out = await call(tokenOf("pixel"));
92 expect(out.error).toBe(true);
93 expect(out.text).toContain("Flags are set by a forge admin.");
94 });
95 });
test/mcp.test.ts +1
@@ -293,6 +293,7 @@ describe("the forge over MCP", () => {
293293 "access",
294294 "capacity",
295295 "scorecard",
296 "flags",
296297 "flow",
297298 "system",
298299 "telemetry_history",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs