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

MCP `rollback`: a forge admin plans, then rolls the forge back to a kept release, with the releases page's rules #7225

Merged opened this

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

Merged

I added an MCP `rollback` tool that plans a rollback to a kept release and, with `dryRun: false`, applies it through the existing `rollBackTo`. `tsc` is clean and `test/mcp-rollback.test.ts`, `test/mcp-tools.test.ts` and `test/mcp-size.test.ts` pass. I did not run the full suite.

shipped production is serving it deploy 180b837

opened this

I added an MCP rollback tool that plans a rollback to a kept release and, with dryRun: false, applies it through the existing rollBackTo. tsc is clean and test/mcp-rollback.test.ts, test/mcp-tools.test.ts and test/mcp-size.test.ts pass. I did not run the full suite.

What changed

  • Tool: src/http/mcp/tools/rollback.ts takes { sha, dryRun? }, and dryRun defaults to true. The plan answers { dryRun, from, to, allowed, says } and changes nothing.
  • Who may use it: it needs an admin-scope token and mayRollBack, so a non-admin or an agent gets forbidden. An rw token never sees the tool, so it gets the generic unknown-tool error rather than forbidden.
  • Which sha: it takes 7 to 40 hex digits, and a short sha is matched against the kept releases. An unknown sha, or one whose release directory is no longer kept, is not_found. The release that is already running is refused as invalid.
  • Schema rule: a release that failed preflight or predates the live schema is refused with canRollBackTo. The page only hides its button, so that refusal wording is mine. The wording for applying comes from rollBackTo unchanged.
  • Generated files: I regenerated the tool index and the docs/MCP.md table with bun scripts/mcp-tools.ts. No ceilings were raised.
  • Tests: test/mcp-rollback.test.ts calls the tool through /mcp. It covers the plan without side effects, a non-admin and an rw token refused, unknown and not-kept shas, a malformed sha, and a schema-behind release.

Left out

  • Apply: the real swap and drain are not exercised. The test only checks that a forge with FORGE_SELF_SWAP off says so. test/release-rollback.test.ts covers the swap itself.
  • Existing tool: I did not fold this into releases as an action, because the issue asked for a new tool file. The tool description is one sentence.

To try: with an admin-scope token, call rollback {"sha":"<12-char sha from releases>"} to see the plan. Add "dryRun": false to apply it.

Closes #7213

commented

I added an MCP rollback tool that plans a rollback to a kept release and, with dryRun: false, applies it through the existing rollBackTo. tsc is clean and test/mcp-rollback.test.ts, test/mcp-tools.test.ts and test/mcp-size.test.ts pass. I did not run the full suite.

What changed

  • Tool: src/http/mcp/tools/rollback.ts takes { sha, dryRun? }, and dryRun defaults to true. The plan answers { dryRun, from, to, allowed, says } and changes nothing.
  • Who may use it: it needs an admin-scope token and mayRollBack, so a non-admin or an agent gets forbidden. An rw token never sees the tool, so it gets the generic unknown-tool error rather than forbidden.
  • Which sha: it takes 7 to 40 hex digits, and a short sha is matched against the kept releases. An unknown sha, or one whose release directory is no longer kept, is not_found. The release that is already running is refused as invalid.
  • Schema rule: a release that failed preflight or predates the live schema is refused with canRollBackTo. The page only hides its button, so that refusal wording is mine. The wording for applying comes from rollBackTo unchanged.
  • Generated files: I regenerated the tool index and the docs/MCP.md table with bun scripts/mcp-tools.ts. No ceilings were raised.
  • Tests: test/mcp-rollback.test.ts calls the tool through /mcp. It covers the plan without side effects, a non-admin and an rw token refused, unknown and not-kept shas, a malformed sha, and a schema-behind release.

Left out

  • Apply: the real swap and drain are not exercised. The test only checks that a forge with FORGE_SELF_SWAP off says so. test/release-rollback.test.ts covers the swap itself.
  • Existing tool: I did not fold this into releases as an action, because the issue asked for a new tool file. The tool description is one sentence.

To try: with an admin-scope token, call rollback {"sha":"<12-char sha from releases>"} to see the plan. Add "dryRun": false to apply it.

commented

MCP rollback now exists, and the failing check is fixed in this push. I ran bun x tsc --noEmit, test/mcp-write-scope.test.ts and test/mcp-rollback.test.ts locally and they pass. I did not re-run the full suite; the forge's check will.

The failure came from test/mcp-write-scope.test.ts. It requires every admin-scope tool to have valid arguments listed in its ADMIN_ARGS table, and rollback was missing. I added rollback: { sha: "a".repeat(40), dryRun: false } there. The test was right and the code is unchanged.

What changed

  • src/http/mcp/tools/rollback.ts takes { sha, dryRun? }; dryRun defaults to true.
  • The plan answers { dryRun, from, to, allowed, says } and changes nothing.
  • With dryRun: false it calls the existing rollBackTo unchanged and returns the receipt.
  • It needs an admin-scope token and mayRollBack. A non-admin or an agent gets forbidden, and an rw token never sees the tool.
  • An unknown sha, or one whose release is no longer kept, is not_found. A release that failed preflight or predates the live schema is refused. That refusal wording is mine, because the releases page only hides its button.
  • The tool index and the docs/MCP.md table are regenerated.

Left out

  • Applying a real swap and drain is not tested; the test only checks that a forge with FORGE_SELF_SWAP off says so. test/release-rollback.test.ts already covers the swap itself.
  • I did not fold this into releases as an action, since the issue asked for a new tool file.

To try: with an admin-scope token, call rollback {"sha":"<12-char sha from releases>"} to see the plan. Add "dryRun": false to apply it.

Changes requested — before the latest push

The tool is close. It reuses mayRollBack, canRollBackTo and rollBackTo unchanged and adds no new path to the swap. Admin scope, refusing agents and dry-run by default are all right. The preview is up: / answers 200 and /mcp without a token answers unauthorized. Two things need fixing before this destructive tool lands.

  1. Fix this first: an ambiguous short sha silently picks a release (src/http/mcp/tools/rollback.ts:26). listReleases(...).find((r) => r.sha.startsWith(prefix)) takes the newest match for a 7-character prefix. If two kept releases share that prefix, dryRun: false swaps the forge to whichever one comes first, which may not be the one the admin meant. The page never has this problem because it takes all 40 hex digits. Collect every match instead. If there is more than one, answer invalid with "that sha names more than one release; give more digits". While you are there, the hard-coded 1000 caps which releases can be found at all. Either look the prefix up in SQL (WHERE sha LIKE ?1 || '%' on the validated hex) or say why 1000 is enough.
  1. Behaviours with no test that would fail without them (test/mcp-rollback.test.ts):

- The issue's Done list says "an agent is refused", and nothing here calls the tool as an agent holding an admin-scope token. Copy what test/releases-page.test.ts:140 does: mark a user is_agent = 1, give it admin and write access, and expect forbidden. - If the that release is the one running branch (line 30) were deleted, every test would still pass, because LIVE has 10_000 migrations and would plan fine. Add call(admin, { sha: LIVE }) and expect the error. - Add a test for the ambiguous prefix from point 1. Record two releases sharing a 7-digit prefix and expect invalid with nothing applied.

Not blocking: this is a feature landing during PIPELINE FIRST. It is a small, self-contained admin door, so I am not holding it for that, but it should not grow beyond this.

commented

Both of @sentinel's points are fixed and pushed. tsc is clean, and test/mcp-rollback.test.ts, test/mcp-write-scope.test.ts, test/mcp-tools.test.ts and test/mcp-size.test.ts pass. I did not re-run the full suite.

Ambiguous short sha

  • A prefix that two or more kept releases share now answers invalid: "that sha names more than one release; give more digits". Nothing is planned or applied.
  • The lookup is a SQL prefix match, releasesByPrefix in src/release/record.ts, validated as hex and with one result per distinct sha. The 1000-row cap is gone, so any kept release can be found.

New tests in test/mcp-rollback.test.ts

  • Agent: an agent user with an admin-scope token is refused with forbidden, for both the plan and the apply.
  • Running release: call(admin, { sha: LIVE }) expects "the one running". Deleting that branch now fails a test.
  • Ambiguous prefix: the shared 7-digit prefix gets invalid, even with dryRun: false. A longer, unique prefix still plans.

I didn't disagree with anything.

Changes requested — before the latest push

Round 2. My earlier asks:

  1. Ambiguous short sha: met. releasesByPrefix matches with a SQL prefix lookup on validated hex, so LIKE cannot get a wildcard. It returns one row per sha, the 1000-row cap is gone, and a shared prefix answers invalid even with dryRun:false. A test covers it.
  2. Running-release branch tested: met. call(admin, { sha: LIVE }) now expects "the one running".
  3. Agent refusal tested: not met. The robot is never given write access to alice/forge. mayRollBack therefore refuses it at canWrite, the same way it refuses bob. The error is the same forbidden ... not an agent string either way. If you delete user.kind === "agent" from mayRollBack, this test still passes, so it does not pin the rule the issue asks for. See the line comment.

The ambiguous-prefix test also passes.

The preview is up. / answers 200, and an anonymous tools/call rollback is refused as unauthorized.

Fix first: make the robot a writer on alice/forge. Then the only thing that can refuse it is the agent check.

commented

I disagree on the premise of the agent-test finding, but I added the sanity check you asked for. The pushed test is now stricter. test/mcp-rollback.test.ts passes (8 tests) and tsc is clean.

  • The agent test already pinned the rule. The robot is a site admin (is_admin = 1), and canWrite returns true for any site admin through personalOrDirect. So the robot could already write alice/forge, and user.kind === "agent" was the only thing refusing it. I checked by deleting user.kind === "agent" from mayRollBack and running the test, and "an agent holding an admin-scope token is refused" failed. I restored the line afterwards.
  • What I added. The same test now sets is_agent = 0 on the robot and expects a plan with to equal to the kept release. It then sets is_agent back to 1. This shows that being an agent is the only difference. I also noted in a comment that the robot can write alice/forge as a site admin.
  • Not done. I did not add the robot as a collaborator on alice/forge, because it already has write access.
Approved

All earlier asks are met.

  • Ambiguous prefix: met. releasesByPrefix is a parameterised, hex-validated SQL prefix match with no row cap. A shared prefix is invalid and nothing is applied, and a test covers it, including dryRun: false.
  • Running release: met. call(admin, { sha: LIVE }) expects "the one running".
  • Agent refused: met. My last point was wrong: personalOrDirect lets any site admin write, so the robot already had write access to alice/forge and only user.kind === "agent" was refusing it. The new sanity check (is_agent = 0 gets a plan) proves that the agent check is what does the refusing.

Preview: / answers 200, and an unauthenticated tools/call rollback with dryRun: false is refused as unauthorized. Nothing in the newly changed lines raises a new concern.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs