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

MCP `flag_winner`: make an A/B flag's winning variant the default, through makeWinnerDefault #7411

Merged opened this

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

Merged

scout/forge:scout/5440-mcp-flag-winner-make-an-a-b-flag-s-winni → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/5440-mcp-flag-winner-make-an-a-b-flag-s-winni was merged into main as e3fc2a2.

Review

Sign in to review this change.
5 files changed +143 −0
docs/MCP.md +1
@@ -82,6 +82,7 @@ for a tunnel and a forge that sleeps.
8282 | `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 |
8383 | `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 |
8484 | `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 |
85 | `flag_winner` | `{ name, winner }`: make an A/B flag's winning variant (`on` or `off`, only the one the read-out offers) its default for everyone, opening the clean-up issue first; a forge admin with an admin token; answers `{ name, winner, issue, says }` | yes |
8586 | `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` | |
8687 | `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` | |
8788 | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes |
src/http/mcp/tools/flag_winner.ts added +32
@@ -0,0 +1,32 @@
1 import { makeWinnerDefault } from "../../../actions/flag_winner.ts";
2 import { mayManageFlags } from "../../../core/flags.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
4 import { good, bad, type Tool } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "flag_winner",
8 doc: "`{ name, winner }`: make an A/B flag's winning variant (`on` or `off`, only the one the read-out offers) its default for everyone, opening the clean-up issue first; a forge admin with an admin token; answers `{ name, winner, issue, says }`",
9 description: "An A/B flag winner becomes default.",
10 inputSchema: {
11 type: "object",
12 properties: { name: { type: "string" }, winner: { enum: ["on", "off"] } },
13 required: ["name", "winner"],
14 },
15 writes: true,
16 run(ctx, user, args) {
17 if (!mayManageFlags(user)) return bad("forbidden", "Flags are set by a forge admin.");
18 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
19 return bad("forbidden", "a flag's default needs an admin token");
outdated

High: deleting this line leaves every test green, because each test uses an admin-scope token. Add a test where a forge admin calls with a rw token and expect forbidden, the flag unchanged and no issue opened. Also move this check, together with a mayManageFlags(user) check, above the input validation at line 17, as flag_set does.

20 const { name, winner } = args;
21 if (typeof name !== "string" || (winner !== "on" && winner !== "off"))
22 return bad("invalid", "send a flag name and winner, on or off");
23 const done = makeWinnerDefault(ctx.app, user, name, winner);
24 if (!done.ok) return bad("refused", done.message);
25 return good({
26 name,
27 winner,
28 issue: done.issue.number,
29 says: `${name} is ${winner} for everyone; issue #${done.issue.number} asks the crew to remove the other path.`,
30 });
31 },
32 };
src/http/mcp/tools/index.ts +1
@@ -52,6 +52,7 @@ export { tool as feed } from "./feed.ts";
5252 export { tool as feed_chips } from "./feed_chips.ts";
5353 export { tool as feed_dismiss } from "./feed_dismiss.ts";
5454 export { tool as flag_set } from "./flag_set.ts";
55 export { tool as flag_winner } from "./flag_winner.ts";
5556 export { tool as flags } from "./flags.ts";
5657 export { tool as flow } from "./flow.ts";
5758 export { tool as follow_mode } from "./follow_mode.ts";
test/mcp-flag-winner.test.ts added +108
@@ -0,0 +1,108 @@
1 /** MCP `flag_winner`: the page's make-the-winner-default, from a harness. */
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, useFlagRegistry } from "../src/core/flags.ts";
9 import { createRepo } from "../src/core/repos.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 const PASS = "a-long-flag-passphrase";
18 const reg = makeFlags({ gamma: { why: "tries the new board", default: true, issue: 5440 } });
19
20 describe("MCP flag_winner", () => {
21 let dataDir = "";
22 let app: App;
23 let servers: Servers;
24
25 const id = (name: string) => getUserByName(app.db, name)!.id;
26 const winner = async (token: string, flagName: string, win: string) => {
27 const res = await fetch(`${servers.url}/mcp`, {
28 method: "POST",
29 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
30 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "flag_winner", arguments: { name: flagName, winner: win } } }),
31 });
32 const body = (await res.json()) as any;
33 return { error: body.result.isError === true, text: body.result.content[0].text as string };
34 };
35
36 beforeAll(async () => {
37 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-flagwin-"));
38 app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge" });
39 servers = await serve(app);
40 for (const [username, isAdmin] of [["alice", true], ["bob", 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 const repo = await createRepo(app.db, app.reposRoot, { ownerId: id("alice"), name: "forge", visibility: "public" });
45 if (!repo.ok) throw new Error(String(repo.error));
46 useFlagRegistry(reg);
47 for (let i = 0; i < 60; i++) reg.flag("gamma"); // on x60
48 reg.set(app.db, "gamma", { users: ["u1"] }, "x");
49 }, 30_000);
50
51 afterAll(async () => {
52 useFlagRegistry(undefined);
53 servers.stop();
54 app.close();
55 await rm(dataDir, { recursive: true, force: true });
56 });
57
58 test("a non-admin is refused and nothing changes", async () => {

Add the missing cases next to this one. (a) alice, an admin, with createToken(..., "rw") is refused and nothing changes. (b) winner: "maybe" is refused as invalid and the flag is unchanged.

59 const bob = createToken(app.db, id("bob"), "t", "admin").token;
60 const r = await winner(bob, "gamma", "on");
61 expect(r.error).toBe(true);
62 expect(r.text).toContain("Flags are set by a forge admin.");
63 expect(reg.value("gamma")).toEqual({ users: ["u1"] });
64 expect(app.db.query("SELECT 1 FROM issue").all()).toEqual([]);
65 });
66
67 test("an admin's everyday rw or ro token is refused and nothing changes", async () => {
68 for (const scope of ["rw", "ro"] as const) {
69 const token = createToken(app.db, id("alice"), "t", scope).token;
70 const r = await winner(token, "gamma", "on");
71 expect(r.error).toBe(true);
72 expect(r.text).toContain("forbidden");
73 expect(reg.value("gamma")).toEqual({ users: ["u1"] });
74 expect(app.db.query("SELECT 1 FROM issue").all()).toEqual([]);
75 }
76 });
77
78 test("a winner that is not on or off is invalid and nothing changes", async () => {
79 const admin = createToken(app.db, id("alice"), "t", "admin").token;
80 const r = await winner(admin, "gamma", "maybe");
81 expect(r.error).toBe(true);
82 expect(r.text).toContain("invalid");
83 expect(reg.value("gamma")).toEqual({ users: ["u1"] });
84 expect(app.db.query("SELECT 1 FROM issue").all()).toEqual([]);
85 });
86
87 test("a winner the read-out does not offer is refused and the flag is unchanged", async () => {
88 const admin = createToken(app.db, id("alice"), "t", "admin").token;
89 const r = await winner(admin, "gamma", "off");
90 expect(r.error).toBe(true);
91 expect(r.text).toContain("That variant is not the winner yet.");
92 expect(reg.value("gamma")).toEqual({ users: ["u1"] });
93 expect(app.db.query("SELECT 1 FROM issue").all()).toEqual([]);
94 });
95
96 test("the offered winner becomes the default and the issue exists", async () => {
97 const admin = createToken(app.db, id("alice"), "t", "admin").token;
98 const r = await winner(admin, "gamma", "on");
99 expect(r.error).toBe(false);
100 const out = JSON.parse(r.text);
101 expect(out.name).toBe("gamma");
102 expect(out.winner).toBe("on");
103 expect(typeof out.says).toBe("string");
104 expect(reg.value("gamma")).toEqual({ forge: true });
105 const row = app.db.query<{ title: string }, [number]>("SELECT title FROM issue WHERE number = ?1").get(out.issue);
106 expect(row?.title).toContain("Remove flag gamma");
107 });
108 });
test/mcp.test.ts +1
@@ -227,6 +227,7 @@ describe("the forge over MCP", () => {
227227 "invites",
228228 "account_role",
229229 "account_suspend",
230 "flag_winner",
230231 "restore_drill",
231232 "model_ceiling",
232233 "account_verify",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs