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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5417-mcp-flags-every-declared-flag-why-it-exi → main 8 commits fork 1 thread · 1 unresolved
6 files
changed +129
−0
docs/MCP.md +1
| @@ -77,6 +77,7 @@ for a tunnel and a forge that sleeps. | ||
| 77 | 77 | | `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 | |
| 78 | 78 | | `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 | |
| 79 | 79 | | `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` | | | |
| 80 | 81 | | `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` | | |
| 81 | 82 | | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes | |
| 82 | 83 | | `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) { | ||
| 216 | 216 | value(name: keyof D & string): FlagValue | undefined { |
| 217 | 217 | return values.get(name); |
| 218 | 218 | }, |
| 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 | }, | |
| 219 | 227 | /** |
| 220 | 228 | * Store `value` for a declared flag (`undefined` clears it), keep the |
| 221 | 229 | * 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 |
||
| 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"; | ||
| 50 | 50 | export { tool as feed_chips } from "./feed_chips.ts"; |
| 51 | 51 | export { tool as feed_dismiss } from "./feed_dismiss.ts"; |
| 52 | 52 | export { tool as flag_set } from "./flag_set.ts"; |
| 53 | export { tool as flags } from "./flags.ts"; | |
| 53 | 54 | export { tool as flow } from "./flow.ts"; |
| 54 | 55 | export { tool as follow_mode } from "./follow_mode.ts"; |
| 55 | 56 | 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", () => { | ||
| 293 | 293 | "access", |
| 294 | 294 | "capacity", |
| 295 | 295 | "scorecard", |
| 296 | "flags", | |
| 296 | 297 | "flow", |
| 297 | 298 | "system", |
| 298 | 299 | "telemetry_history", |
new above
offline
Nit: use
FLAGS_ADMIN_ONLYfromcore/flags.tsinstead of repeating the string. Its doc says it is the one wording the action, the page and the terminal share.flag_setrepeats the literal too, so both could move to the constant in a later pull.