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

MCP `flag_set`: set or clear a flag's live value, with the same checks as the Flags page #5482

Merged opened this

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

Merged

scout/forge:scout/5424-mcp-flag-set-set-or-clear-a-flag-s-live → main 10 commits fork 12 threads · 12 unresolved

  • Merged. scout/5424-mcp-flag-set-set-or-clear-a-flag-s-live was merged into main as 5a354fe.

Review

Sign in to review this change.
11 files changed +209 −6
docs/MCP.md +1
@@ -71,6 +71,7 @@ for a tunnel and a forge that sleeps.
7171 | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | |
7272 | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out | |
7373 | `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 |
74 | `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 |
7475 | `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` | |
7576 | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes |
7677 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
docs/OPERATING.md +1 −1
@@ -157,7 +157,7 @@ and an accessibility tree, which nothing dependency-free gives.
157157
158158 ## Flags
159159
160 A forge admin sees every declared flag at `/settings/flags` (Settings → Flags): why it exists, its default, the owner issue and its live value. Set it for the whole forge, for listed user ids or roles, or for a percentage of people; Clear returns it to the default. A change is validated, kept in the change record and live on the next request, with no restart. In the terminal the same admin opens the command palette (`ctrl+k`), picks Flags, and presses enter to set a flag from a line such as `forge=on users=u1,u2 roles=admin percent=30` or `x` to clear it; an agent is never an admin there either.
160 A forge admin sees every declared flag at `/settings/flags` (Settings → Flags): why it exists, its default, the owner issue and its live value. Set it for the whole forge, for listed user ids or roles, or for a percentage of people; Clear returns it to the default. A change is validated, kept in the change record and live on the next request, with no restart. In the terminal the same admin opens the command palette (`ctrl+k`), picks Flags, and presses enter to set a flag from a line such as `forge=on users=u1,u2 roles=admin percent=30` or `x` to clear it; an agent is never an admin there either. The MCP `flag_set` tool does the same with the same checks, for an admin token.
161161
162162 Settings → Usage is one page for a forge admin, opening at `/settings/spend`, with four tabs: Spend, Routes (`/settings/usage`), Capacity (`/settings/capacity`) and Telemetry (`/settings/telemetry`); each tab keeps its own URL and permission. Spend is admin-only (anyone else gets 404) and holds what the crew's runs spent, by credential, app, agent, issue and run kind; it used to sit on Subscriptions, which now links to it. Routes lists every route the router knows: its requests over the last 30 days, by role and by surface, and the hour of its last use. Routes with no request in the window are listed first, never-used ones before the longest-unused, as candidates to drop; the rest follow by count. Counts are per pattern, not per method. `usage` is also a boards source, for an admin only (an agent is refused); its `hour` column is text like `2026-10-16T13`, so group by a column such as `pattern` or `role`, not by a time bucket. In the terminal the same admin opens the command palette (`ctrl+k`), picks Usage, and reads the Routes list only — the spend breakdown, capacity and telemetry tabs are web-only, and the terminal has never shown spend; an agent is never an admin there either.
163163
src/http/mcp/tools/asked.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "asked",
88 doc: "`{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write",
99 description:
10 "What people asked the crew for where you write: by default unmerged, oldest first.",
10 "What people asked the crew for: unmerged by default, oldest first.",

Nit: "where you write" was the only scope hint in the description, so an agent reading tools/list alone no longer knows the list is limited to repositories it can write. Consider putting it back if a later trim frees the bytes.

1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/converge.ts +1 −1
@@ -8,7 +8,7 @@ export const tool: Tool = {
88 name: "converge",
99 doc: "the fork's crew merges where both sides changed the same files; comes back as a pull",
1010 description:
11 "Have a fork's crew merge the parent's updates where both changed a file, as a pull.",
11 "Have a fork's crew merge the parent's updates where both changed a file.",
1212 inputSchema: {
1313 type: "object",
1414 properties: { repo: REPO_ARG },
src/http/mcp/tools/flag_set.ts added +65
@@ -0,0 +1,65 @@
1 import { checkInput, flagRegistry, mayManageFlags } from "../../../core/flags.ts";
2 import { scopeAllows } from "../../../core/tokens.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "flag_set",
7 doc: "`{ 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 }`",
8 description: "Set or clear a flag (admin).",
9 inputSchema: {
10 type: "object",
11 properties: {
12 name: { type: "string" },
13 value: { type: "object" },
14 clear: { type: "boolean" },
15 },
16 required: ["name"],
17 },
18 writes: true,
19 run(ctx, user, args) {
20 if (!mayManageFlags(user)) return bad("forbidden", "Flags are set by a forge admin.");
21 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))

No test fails if this check is removed: both test tokens have scope admin. Add a case where an admin's non-admin-scope token is refused and the flag stays unset.

22 return bad("forbidden", "setting a flag needs an admin token");
23 const { name, value, clear } = args;
24 const shape = "value is { forge?: boolean, users?: string[], roles?: string[], percent?: number }";
25 if (typeof name !== "string" || (clear !== undefined && typeof clear !== "boolean"))
26 return bad("invalid", "send a flag name, and value or clear");
27 if (clear !== true && (typeof value !== "object" || value === null || Array.isArray(value)))
28 return bad("invalid", "send a value object, or clear: true");
29 const o = (clear === true ? {} : value) as Record<string, unknown>;
30 const words = (x: unknown): string[] | undefined =>
31 x === undefined
32 ? []
33 : Array.isArray(x) && x.every((w) => typeof w === "string" && !/[\s,]/.test(w))
34 ? x
35 : undefined;
36 const users = words(o["users"]);
37 const roles = words(o["roles"]);
38 if (
39 Object.keys(o).some((k) => !["forge", "users", "roles", "percent"].includes(k)) ||
40 !(o["forge"] === undefined || typeof o["forge"] === "boolean") ||
41 !(o["percent"] === undefined || typeof o["percent"] === "number") ||
42 !users ||
43 !roles
44 )
45 return bad("invalid", shape);
46 const checked =
47 clear === true
48 ? ({ ok: true, value: undefined } as const)
49 : checkInput({
50 forge: o["forge"] === undefined ? "" : o["forge"] ? "on" : "off",
51 users: users.join(","),
52 roles: roles.join(","),
53 percent: o["percent"] === undefined ? "" : String(o["percent"]),
54 });
55 if (!checked.ok) return bad("invalid", checked.message);
56 const registry = flagRegistry();
57 if (!registry.set(ctx.app.db, name, checked.value, user.id))
58 return bad("not_found", "There is no such flag.");
59 return good({
60 name,
61 value: registry.value(name as never) ?? null,
62 says: `${name}: ${checked.value === undefined ? "cleared" : "saved"}.`,
63 });
64 },
65 };
src/http/mcp/tools/index.ts +1
@@ -47,6 +47,7 @@ export { tool as explore } from "./explore.ts";
4747 export { tool as family } from "./family.ts";
4848 export { tool as feed } from "./feed.ts";
4949 export { tool as feed_chips } from "./feed_chips.ts";
50 export { tool as flag_set } from "./flag_set.ts";
5051 export { tool as flow } from "./flow.ts";
5152 export { tool as follow_mode } from "./follow_mode.ts";
5253 export { tool as following } from "./following.ts";
src/http/mcp/tools/runs.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "runs",
77 doc: "`{ repo, limit? }`: the repository's recent runs, newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them",
88 description:
9 "A repository's runs, newest first: id, kind, state, issue or pull number, start, end.",
9 "A repository's runs, newest first: id, kind, state, number, start, end.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/scorecard.ts +1 −1
@@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts";
55 export const tool: Tool = {
66 name: "scorecard",
77 description:
8 "Crew scorecard (forge admin): per agent, all time and 30 days: landed, rounds, red rate.",
8 "Crew scorecard (forge admin): per agent, all time and 30 days.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: false,
1111 doc: "(forge admin) no input: the crew's scorecard per agent, all time and 30 days: landed, review rounds, red rate, reverts",
src/http/mcp/tools/thought.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "thought",
77 doc: "`{ id }`: where one of your thoughts went (its id comes from `tell` without a repo): the placement, and the earlier thoughts it repeats or takes back and the later ones that did the same; not yours and not there answer alike, `not_found`",
88 description:
9 "Where a thought went: its id (from `tell` without a repo) answers placement and reply.",
9 "Where a thought went, by its id (from `tell` without a repo).",
outdated

Low: the trim left this sentence ending in "answers." with no object, so an agent reading tools/list cannot tell what it gets back. "Where a thought went, by its id (from tell without a repo)." costs about the same bytes. Do it in a follow-up if the budget allows.

1010 inputSchema: {
1111 type: "object",
1212 properties: { id: { type: "string", maxLength: 64 } },
test/mcp-flag-set.test.ts added +135
@@ -0,0 +1,135 @@
1 /** MCP `flag_set`: the Flags page's write, from a harness, under its rules. */
2 import { afterAll, beforeAll, describe, 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 { makeFlags } from "../src/core/flags.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { useFlagRegistry } from "../src/web/pages/flags.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 const reg = makeFlags({ alpha: { why: "shows the new board", default: false, issue: 1 } });
17
18 describe("MCP flag_set", () => {
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let admin = "";
23 let bob = "";
24 let aliceRw = "";
25 let id = 1;
26
27 const call = async (token: string, args: unknown) => {
28 const res = await fetch(`${servers.url}/mcp`, {
29 method: "POST",
30 headers: {
31 "content-type": "application/json",
32 accept: "application/json, text/event-stream",
33 authorization: `Bearer ${token}`,
34 },
35 body: JSON.stringify({
36 jsonrpc: "2.0",
37 id: id++,
38 method: "tools/call",
39 params: { name: "flag_set", arguments: args },
40 }),
41 });
42 const body = (await res.json()) as any;
43 const text: string = body.result.content[0].text;
44 return { text, error: body.result.isError === true };
45 };
46
47 const page = async (): Promise<string> => {
48 const signin = await fetch(`${servers.url}/signin`, {
49 method: "POST",
50 redirect: "manual",
51 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
52 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
53 });
54 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
55 return (await fetch(`${servers.url}/settings/flags`, { headers: { cookie } })).text();
56 };
57
58 beforeAll(async () => {
59 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-flag-"));
60 app = createApp({ dataDir, port: 0 });
61 servers = await serve(app);
62 const mk = async (username: string, adminIfFirst: boolean) => {
63 const u = await createUser(app.db, {
64 origin: "seed",
65 username,
66 email: `${username}@forge.local`,
67 password: "a-good-passphrase",
68 trusted: true,
69 adminIfFirst,
70 });
71 if (!u.ok) throw new Error(u.error);
72 return u.value;
73 };
74 const a = await mk("alice", true);
outdated

The issue asks that this case also shows the new value on the Flags page. Add a GET /settings/flags as the admin and assert the value is there.

75 const b = await mk("bob", false);
76 admin = createToken(app.db, a.id, "admin", "admin").token;
77 aliceRw = createToken(app.db, a.id, "rw", "rw").token;
78 bob = createToken(app.db, b.id, "admin", "admin").token;
79 useFlagRegistry(reg);
80 });
81 afterAll(async () => {
82 useFlagRegistry(undefined);
83 await servers.stop();
84 await rm(dataDir, { recursive: true, force: true });
85 });
86
87 test("an admin sets a declared flag and the registry meets it", async () => {
88 const r = await call(admin, { name: "alpha", value: { forge: true, percent: 30 } });
89 expect(r.error).toBe(false);
90 expect(JSON.parse(r.text)).toEqual({ name: "alpha", value: { forge: true, percent: 30 }, says: "alpha: saved." });
91 expect(reg.value("alpha")).toEqual({ forge: true, percent: 30 });
92 const html = await page();
93 expect(html).toContain('<option value="on" selected');
94 expect(html).toContain('aria-label="alpha percent"');
95 expect(html).toMatch(/value="30"[^>]*aria-label="alpha percent"/);
96 });
97
98 test("clear returns it to its default", async () => {
99 const r = await call(admin, { name: "alpha", clear: true });
100 expect(JSON.parse(r.text)).toEqual({ name: "alpha", value: null, says: "alpha: cleared." });
101 expect(reg.value("alpha")).toBeUndefined();
102 });
103
104 test("an unknown flag and a bad value are refused", async () => {
105 const unknown = await call(admin, { name: "nope", value: { forge: true } });
106 expect(unknown.error).toBe(true);
107 expect(unknown.text).toContain("There is no such flag.");
108 const bad = await call(admin, { name: "alpha", value: { percent: 101 } });
109 expect(bad.error).toBe(true);
110 expect(bad.text).toContain("percent is a whole number from 0 to 100.");
111 expect(reg.value("alpha")).toBeUndefined();
112 for (const value of [{ users: [42] }, { users: ["a,b"] }, { roles: "admin" }, { percent: "5" }, { fore: true }]) {
113 const r = await call(admin, { name: "alpha", value });
114 expect(r.error).toBe(true);
115 expect(r.text).toContain("value is {");
116 }
117 expect(reg.value("alpha")).toBeUndefined();
118 });
119
120 test("a non-admin is refused in the page's words", async () => {
121 const r = await call(bob, { name: "alpha", value: { forge: true } });
122 expect(r.error).toBe(true);
123 expect(r.text).toContain("Flags are set by a forge admin.");
124 const junk = await call(bob, { name: 7, value: 3 });
125 expect(junk.text).toContain("Flags are set by a forge admin.");
126 expect(reg.value("alpha")).toBeUndefined();
127 });
128
129 test("an admin's non-admin-scope token is refused and nothing changes", async () => {
130 const r = await call(aliceRw, { name: "alpha", value: { forge: true } });
131 expect(r.error).toBe(true);
132 expect(r.text).toContain("setting a flag needs an admin token");
133 expect(reg.value("alpha")).toBeUndefined();
134 });
135 });
test/mcp.test.ts +1
@@ -240,6 +240,7 @@ describe("the forge over MCP", () => {
240240 "claim_link",
241241 "take_updates",
242242 "sever",
243 "flag_set",
243244 "follow_mode",
244245 "upstream",
245246 "propose_to_parent_forge",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs