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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5424-mcp-flag-set-set-or-clear-a-flag-s-live → main 10 commits fork 12 threads · 12 unresolved
docs/MCP.md +1
| @@ -71,6 +71,7 @@ for a tunnel and a forge that sleeps. | ||
| 71 | 71 | | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | | |
| 72 | 72 | | `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 | | |
| 73 | 73 | | `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 | | |
| 74 | 75 | | `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` | | |
| 75 | 76 | | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes | |
| 76 | 77 | | `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. | ||
| 157 | 157 | |
| 158 | 158 | ## Flags |
| 159 | 159 | |
| 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. | |
| 161 | 161 | |
| 162 | 162 | 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. |
| 163 | 163 |
src/http/mcp/tools/asked.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "asked", |
| 8 | 8 | 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", |
| 9 | 9 | 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.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/converge.ts +1 −1
| @@ -8,7 +8,7 @@ export const tool: Tool = { | ||
| 8 | 8 | name: "converge", |
| 9 | 9 | doc: "the fork's crew merges where both sides changed the same files; comes back as a pull", |
| 10 | 10 | 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.", | |
| 12 | 12 | inputSchema: { |
| 13 | 13 | type: "object", |
| 14 | 14 | 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 |
||
| 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"; | ||
| 47 | 47 | export { tool as family } from "./family.ts"; |
| 48 | 48 | export { tool as feed } from "./feed.ts"; |
| 49 | 49 | export { tool as feed_chips } from "./feed_chips.ts"; |
| 50 | export { tool as flag_set } from "./flag_set.ts"; | |
| 50 | 51 | export { tool as flow } from "./flow.ts"; |
| 51 | 52 | export { tool as follow_mode } from "./follow_mode.ts"; |
| 52 | 53 | export { tool as following } from "./following.ts"; |
src/http/mcp/tools/runs.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "runs", |
| 7 | 7 | 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", |
| 8 | 8 | 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.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/scorecard.ts +1 −1
| @@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "scorecard", |
| 7 | 7 | 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.", | |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
| 10 | 10 | writes: false, |
| 11 | 11 | 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 = { | ||
| 6 | 6 | name: "thought", |
| 7 | 7 | 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`", |
| 8 | 8 | 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 |
||
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | 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 |
||
| 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", () => { | ||
| 240 | 240 | "claim_link", |
| 241 | 241 | "take_updates", |
| 242 | 242 | "sever", |
| 243 | "flag_set", | |
| 243 | 244 | "follow_mode", |
| 244 | 245 | "upstream", |
| 245 | 246 | "propose_to_parent_forge", |
Nit: "where you write" was the only scope hint in the description, so an agent reading
tools/listalone no longer knows the list is limited to repositories it can write. Consider putting it back if a later trim frees the bytes.