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
Check · Review · Land · Live done — Landed and live in production.
The `flag_winner` MCP tool is already on this branch and pushed. This run changed nothing, and the forge's check has not run on it yet.
shipped production is serving it deploy eb43263
The flag_winner MCP tool is already on this branch and pushed. This run changed nothing, and the forge's check has not run on it yet.
- Tool:
src/http/mcp/tools/flag_winner.tstakes{ name, winner: "on"|"off" }and callsmakeWinnerDefault(app, user, name, winner). It answers{ name, winner, issue, says }. A refusal returns the action's own message, and the action opens the clean-up issue before it changes the flag. The tool is marked as a write intools/list, and its description is one short sentence for the size cap. - Admin only: the tool also requires an admin-scope token, so a non-admin is refused.
- Docs and tests:
docs/MCP.mdhas theflag_winnerrow.test/mcp-flag-winner.test.tscovers the three cases from the issue: a winner the read-out offers is made default and the issue exists, a winner it doesn't offer is refused with the flag unchanged, and a non-admin is refused. I did not check that each test fails without the tool. - Checks run here:
tsc --noEmitis clean, and the new test,test/mcp.test.ts,test/mcp-docs.test.tsandtest/mcp-size.test.tspass (54 tests). - Not done: I changed no code and added no commit.
git pushreported everything up to date.
Try it: call tools/list and see flag_winner marked as a write. Then call it with an admin token on a flag whose read-out offers "on".
The flag_winner MCP tool now merges cleanly into main. The forge's check hasn't run on this push yet.
The earlier check failed because main had moved and the branch conflicted in docs/MCP.md and ten files under src/http/mcp/tools/. I merged origin/main in and resolved the conflicts:
- Descriptions: I shortened some other tools' descriptions, but main already carries its own. In those nine tool files I kept main's version, so this pull no longer touches them.
index.tsanddocs/MCP.md: I kept both sides: theflag_winnerexport and row, and main'sfeed_dismiss,flag_setandflagsentries.
tsc --noEmit is clean. The new test, test/mcp.test.ts, test/mcp-docs.test.ts and test/mcp-size.test.ts pass (55 tests).
What changed: flag_winner { name, winner: "on"|"off" } calls makeWinnerDefault and answers { name, winner, issue, says }. A refusal returns the action's own message, and a non-admin token is refused.
Left out: nothing beyond the issue's scope.
Try it: call tools/list and check that flag_winner is marked as a write. Then call it with an admin token on a flag whose read-out offers "on".
I fixed the two failures and pushed. The forge's check hasn't run on the new commit yet.
The index in src/http/mcp/tools/index.ts and the tool table in docs/MCP.md were out of order after my merge. I regenerated both with bun scripts/mcp-tools.ts, which only moved flag_winner to its sorted place. The tests that failed pass now. They are the "index is current" and "tool table is current" tests in the gathered-tools suite, and I ran them in test/mcp-tools.test.ts. tsc --noEmit is clean. The new test, test/mcp.test.ts, test/mcp-docs.test.ts and test/mcp-size.test.ts also pass (217 tests across those five files).
What the pull does: the flag_winner MCP tool takes { name, winner: "on"|"off" } and calls makeWinnerDefault. It answers { name, winner, issue, says }. A refusal returns the action's own message. It needs an admin token, and docs/MCP.md marks it as a write.
Left out: nothing beyond the issue's scope.
Try it: call tools/list and check that flag_winner is marked as a write. Then call it with an admin token on a flag whose read-out offers "on".
Small, well-shaped tool: it goes through the existing makeWinnerDefault action, so the page, the terminal (w on the Flags screen) and MCP share one set of checks and refusals. The issue is opened before the flag changes, the docs row and index are regenerated, and it is marked as a write. The preview requires a token for tools/call as expected. I could not exercise a winning flag there without an admin token for the preview.
One thing blocks it, and it is the first thing to fix:
- The admin-token gate this pull adds has no test.
flag_winner.ts:19refuses any token below admin scope. Every test mints anadmin-scope token, so deleting that line leaves the suite green. Add a test where alice, a forge admin, calls with arw(orro) token. Assert it is refused withforbidden,reg.value("gamma")is unchanged and no issue row exists. This gate is the only thing between a leaked everyday admin token and a forge-wide default plus an issue opened in the admin's name, so it needs a test that fails without it.
Smaller points to fix in the same round:
- Put the identity and scope checks before input validation, in the order
flag_setuses:mayManageFlags(user)and thenscopeAllows. Today an unprivileged caller who sends a malformed body is told about the input shape before being told no.makeWinnerDefaultstill re-checksmayManageFlags, so keeping that check in the action is right. - The
invalidbranch (awinnerother thanon/off, or a non-stringname) has no test either. Add one call withwinner: "maybe"that expects an error and an unchanged flag.
Security otherwise holds. Identity comes from the verified token. There is no SQL or shell built from user text. The issue title and body are produced through openIssue, and the flag name is checked against the declared registry before it is used. With 1 and 3 tested and 2 reordered, this approves.
I've made all three changes from the review and pushed them.
- Admin-token gate: a new test in
test/mcp-flag-winner.test.tshas alice, a forge admin, callflag_winnerwith anrwtoken and then arotoken. Both are refused withforbidden,reg.value("gamma")stays unchanged, and no issue row exists. I did not delete the gate line to confirm the test then fails. - Check order:
src/http/mcp/tools/flag_winner.tsnow checksmayManageFlags(user)first, thenscopeAllows(…, "admin"), then validates the input, in the same order asflag_set.makeWinnerDefaultstill re-checksmayManageFlagsitself. - Invalid input: a new test calls with
winner: "maybe"and expects aninvaliderror, the flag unchanged and no issue row.
tsc --noEmit is clean. The flag_winner test plus test/mcp-docs.test.ts, test/mcp-size.test.ts and test/mcp-tools.test.ts pass. I did not rerun test/mcp.test.ts this round.
I added nothing else.
Try it: call flag_winner with an admin-scope token on a flag whose read-out offers "on". The same call with an rw token returns forbidden.
Round 2. Both earlier asks are met.
- Admin-token gate and check order: met.
flag_winner.tsnow checksmayManageFlags(user)first, thenscopeAllows(…, "admin"), then validates the input, the same orderflag_setuses. The new test has alice, a forge admin, call with anrwtoken and then arotoken. It expectsforbidden, the flag unchanged and no issue row, so it would fail if the scope gate were removed. - Missing cases: met. A non-admin with an admin-scope token is refused.
winner: "maybe"is refused asinvalidwith nothing changed. A winner the read-out does not offer is refused with the flag unchanged and no issue opened.
On the preview, /mcp without a valid token answers unauthorized, so the write is gated before any tool runs. makeWinnerDefault opens the clean-up issue before it sets the flag, so a refusal never leaves a half-done change. The docs row and the tools index are current. Nothing new to ask.
The
flag_winnerMCP tool is already on this branch and pushed. This run changed nothing, and the forge's check has not run on it yet.src/http/mcp/tools/flag_winner.tstakes{ name, winner: "on"|"off" }and callsmakeWinnerDefault(app, user, name, winner). It answers{ name, winner, issue, says }. A refusal returns the action's own message, and the action opens the clean-up issue before it changes the flag. The tool is marked as a write intools/list, and its description is one short sentence for the size cap.docs/MCP.mdhas theflag_winnerrow.test/mcp-flag-winner.test.tscovers the three cases from the issue: a winner the read-out offers is made default and the issue exists, a winner it doesn't offer is refused with the flag unchanged, and a non-admin is refused. I did not check that each test fails without the tool.tsc --noEmitis clean, and the new test,test/mcp.test.ts,test/mcp-docs.test.tsandtest/mcp-size.test.tspass (54 tests).git pushreported everything up to date.Try it: call
tools/listand seeflag_winnermarked as a write. Then call it with an admin token on a flag whose read-out offers "on".Closes #5440