Skip to content

fix: atomic server removal with full purge and per-server incarnation (Spec 116 PR2; #1559, #1562) - #1600

Merged
Dumbris merged 16 commits into
mainfrom
fix/116-pr2-atomic-removal
Oct 12, 2026
Merged

Dumbris merged 16 commits into
mainfrom
fix/116-pr2-atomic-removal

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Scope

Spec 116 PR2 (specs/116-config-commit-consistency, plan.md §PR2, tasks.md PR2): removal is atomic and complete, and a same-name re-add starts clean. It builds on PR1 (#1590), which already put every removal path on one synchronous commit, so nothing can resurrect a removed server.

  • Purge in the commit transaction (internal/storage/purge.go). ApplyServerCommit deletes the whole purge set of every removed or replaced server in the bbolt transaction that deletes its row. The set is tool and prompt approvals, OAuth tokens and DCR client, OAuth completion events, scan reports and jobs (with the job index), the integrity baseline, tool-call history and server identities, and tool hashes. PurgeReport per name goes to CommitResult.Purged.
  • Exact OAuth key match (B2). ClearOAuthState and the purge delete only name and name_ followed by 16 lowercase hex characters. Removing or logging out foo no longer deletes foo_bar's tokens.
  • In-memory purge. The last-good tool snapshot and capture stamp are dropped under the approval lock. After unlock the commit cancels the refresh schedule, revokes the inspection exemption, evicts dead signature-cache entries and drops the scanner's cached verdict (a purge observer).
  • Removal callers. REST RemoveServer and MCP upstream_servers remove lose their ad hoc cleanup: the REST prefix OAuth clear, the refresh-schedule call and the approval delete. The MCP removal activity record carries metadata.purged.
  • Incarnation-bound approval writers (FR-014). ApproveTools, ApproveAll, Block, BlockAll, SetToolEnabled and SetAllToolsEnabled (plus *At variants), prompt approvals and checkPromptApprovals, and scanner baseline+blocks each capture the incarnation (or take expected_incarnation). Under the server's approval lock, which the removal purges under, they re-check the stored row and return ErrServerReplaced with nothing written. Scanner approval unquarantines through Commit(ExpectIncarnation) (FR-015). The unquarantine baseline promotion is bound to the incarnation of its own commit and requires the row to still be unquarantined (R9.5).
  • Incarnation-conditional async writes. Identity registration, tool-call history (proxied, code_execution nested, replay) and scan job and report saves check the row's incarnation in the same transaction (FR-014a, FR-014c). An unforced scanner approval ignores reports of another incarnation.
  • REST and Web UI (FR-017). generation and incarnation are on server payloads (REST and SSE embed via management) and on the review payload. These routes take an optional expected_incarnation: tools/approve, tools/block, tools/{tool}/enabled, enable_all, disable_all, unquarantine and security/approve. A mismatch returns 409 server_replaced: server '<name>' was removed or replaced since it was reviewed; an unknown server returns 404. The Web UI sends the incarnation of the review or server it rendered. oas/swagger.yaml is regenerated and docs/api/rest-api.md is updated.
  • A test-only mcpproxy debug purge-report / seed-purge-set (build tag qa_hooks, never shipped) for the live checks.

Design decisions and deviations from the spec

  1. OAuth credential binding is deferred to a follow-up "PR2b". This covers T055b, T055d and T055e, the epoch half of T055f, the completion-event incarnation of T055c, and tests T2.9, T2.11, T2.13–T2.16, T2.18 and T2.20. The binding needs the committed ServerGen threaded through the upstream.Manager → managed → core → oauth constructors, and PR3 (bug: stale connect/disconnect workers and supervisor actions re-install old server configs after a newer commit #1563) rewrites those constructors (AddServerConfigAt and friends). Landing it first would make both PRs edit the same constructors.
  2. Startup empty-set guard. A boot or admission commit whose configuration lists no server at all deletes stale rows but keeps their purge set (ServerCommitBatch.RowOnlyRemoves). This mirrors PR1's boot-time orphan-GC guard, so a config that came up empty cannot wipe every approval and credential. An explicit removal, or an ApplyConfig to an empty set, still purges (T2.1 apply_empty_set).
  3. Unstamped rows are not authoritative. A row with incarnation 0 (written before Spec 116, or by a fixture that bypasses the commit) matches any binding. Every committed row is stamped, because ApplyServerCommit refuses zero stamps.
  4. An approval of an unconfigured server now fails with ErrServerNotFound (REST 404) instead of silently doing nothing. Otherwise it could write a record no purge will ever see.
  5. ErrServerReplaced moved to commitview. It is aliased in runtime, with a ServerReplacedError type, so the scanner and httpapi can match it without importing the runtime.
  6. Discovery and capture writes after a re-add were already rejected by PR1's D15 generation check under the approval lock. T2.7 is regression coverage.
  7. Test file layout. There is one test file per layer, not the per-task file names in tasks.md. The shared fixture is internal/storage/purgetest.

Lock order

Unchanged (plan.md §Lock order). Approval writers take L1 for one server (sorted only inside Commit) and then L2. Prompt and scanner writers reach L1 only through Runtime.WithServerApprovalLockAt, never while holding L0 or L3. The in-memory purge takes lastGoodToolsMu (a leaf) under L1, as D15 discovery already does. Refresh-schedule cancellation, exemption revocation and purge observers run after L0 is released. Conditional async writes are single bbolt transactions and add no edge.

Compatibility

  • Additive JSON fields: generation and incarnation on server and review payloads, incarnation on scan jobs and reports. Additive optional request field expected_incarnation.
  • Requests without the field keep working: they are bound to the incarnation current when they arrive.
  • No migration and no new dependencies. Both editions build, and so does -tags qa_hooks.

Verification

  • Failing-first. Commit 897853c adds the tests against PR1 (main 43c5dc8): storage T2.1, T2.6, T2.17 and T046; runtime T2.1, T2.4, T2.12, T2.17 and T2.19; server T2.2, T054, T2.5 and FR-017. All fail on the parent. T2.7 passes on the parent as regression coverage. Tests that use APIs PR2 adds (*At, Purged, conditional writes, T2.8, the httpapi 409 mapping, vitest) were written with the implementation.
  • Race lane. go test -race -tags server -timeout 30m -skip "E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint" ./internal/...: exit 0.
  • Unit runs. go test ./internal/runtime/... ./internal/server/... (CI skip regex) pass. Frontend vitest passes 2601/2601 and vue-tsc is clean.
  • Lint. golangci-lint v2 (.github/.golangci.yml) reports nothing in changed files, both bare and with --build-tags server.
  • Other checks. scripts/check-upstream-write-sites.sh passes. ./scripts/test-api-e2e.sh on a scratch port (LISTEN_PORT=19003): 70/70. make build OK. The Web smoke sweep (scratch port 19005): 111 passed, 3 failed, all on the Profiles and Clients screens this PR does not touch (1100px layout, /clients contrast).
  • Live L2. Scratch daemons on the qa070 harness, baseline main binary against the PR2 binary, same script. The evidence lives outside the repo: docs/qa/0.70.0-ux-2026-10-10/remediation/evidence/116pr2/EVIDENCE.md.
    • Baseline (bug). After a REST remove and same-name re-add, acme still holds 2 prompt approvals, an integrity baseline, scan jobs and reports, and an OAuth completion. The replacement shows the old scan's clean verdict (report_id scan-acme-qa). A stale approve_all approves the replacement's 12 tools (200), and a stale security/approve unquarantines it (200). Servers removed through MCP (foo) and a config edit (bravo) keep their OAuth tokens, prompt approvals, baselines and scans.
    • PR2. The replacement gets a new incarnation (4 → 14), stays quarantined with 12 pending tools and verdict not_scanned. The stale approve and the stale security approve both return 409 server_replaced, and nothing changes. foo and bravo have zero purge-set keys. foo_bar keeps its token and stays connected. In a 30-iteration concurrent remove/re-add vs bound-approve loop, every approval either ran before the removal or answered 409; the final acme has no approved tool.
    • Both binaries. config.json, the API and storage agree, before and after a restart, with no resurrection.

Not run here: the cross-model review rounds, which the lead runs.

Fixes #1559
Part of #1562 (the credential half closes with PR2b, T2.9)

🤖 Generated with Claude Code

…rnation-bound approvals

T2.1/T2.10 (every removal path purges the whole purge set and the
last-good snapshot), T2.2 (a same-name re-add through REST and MCP
inherits nothing), T2.4/T2.17/T2.19 (approvals and the unquarantine
baseline bound to the incarnation), T2.5 (scanner approval across a
remove and re-add), T2.6 (exact OAuth key match, B2), T2.12 (late
identity registration), T046 (toolhash key format), FR-017 (payload
generation/incarnation), plus the PR2 test seams (nil in production).
T2.7 is regression coverage of PR1's D15 check.
… the commit transaction (Spec 116 PR2)

ApplyServerCommit now deletes the purge set of every removed or replaced
server in the row's bbolt transaction: tool and prompt approvals, OAuth
tokens and DCR client, OAuth completion events, scan reports and jobs
(with the job index), the integrity baseline, tool-call history and server
identities, and tool hashes (data-model PurgeSet). ApplyServerCommitWithReport
returns a PurgeReport per name.

ClearOAuthState and the purge match OAuth keys exactly: the bare name or
name_ plus 16 lowercase hex characters, so removing foo no longer deletes
the tokens of foo_bar (B2).

Incarnation-conditional writers for state an asynchronous writer can
produce after a removal: RegisterServerIdentityIfIncarnation,
RecordToolCallIfIncarnation (code_execution exempt), SaveScanJobIfIncarnation
and SaveScanReportIfIncarnation check the stored row in the same
transaction. A startup commit that loads no server keeps the trust state
(RowOnlyRemoves), as the boot-time orphan GC does. commitview gains
ErrServerReplaced and ServerReplacedError so the scanner and the HTTP
layer can match it without importing the runtime.
…writers (Spec 116 PR2; #1559, #1562)

The commit records CommitResult.Purged, drops the last-good tool snapshot
and capture stamp of a purged server under its approval lock, and after the
lock cancels the OAuth refresh schedule, revokes an inspection exemption,
evicts dead signature-cache entries and notifies purge observers. Connect
workers register the server identity only while the row keeps the
incarnation they were started for; replayed calls record history the same
way.

Every approval writer is bound to an incarnation (FR-014): it captures it
when it starts (or takes the caller's expected_incarnation), and under the
server's approval lock - the lock the removal purges under - it re-checks
the stored row and writes nothing (ErrServerReplaced) when the server was
removed or re-added since. Covered: ApproveTools, ApproveAllTools,
BlockTools, BlockAllTools, SetToolEnabled, SetAllToolsEnabled (with *At
variants), WithServerApprovalLockAt for writers outside the runtime, and
QuarantineServerAt (ExpectIncarnation in the commit). The baseline
promotion after an unquarantine is bound to the incarnation of the commit
that unquarantined, and promotes nothing when the row moved or is
quarantined again (review R9.5). Server payloads and the review payload
carry generation and incarnation; removal activity records carry the purge
summary.
…d scanner approvals, expected_incarnation on REST and the Web UI (Spec 116 PR2)

REST and MCP removal use the commit's purge and drop their ad hoc cleanup
(the REST OAuth clear with its prefix match, the refresh-schedule call and
the tool-approval delete). MCP removal records the purge summary.

Prompt approvals (checkPromptApprovals, ApprovePrompt, ApproveAllPrompts)
read and write a server's records under its approval lock after the
incarnation check; RefreshPrompts captures the incarnations before it
lists, so a pass that straddles a remove and re-add records nothing for the
replacement (FR-016).

Scanner approval captures the incarnation, saves the baseline and blocks
under the approval lock only while the row keeps it, and unquarantines
through a commit that expects it (FR-015); a remove and re-add in between
fails it with server_replaced and leaves the replacement quarantined. Scan
jobs and reports record their incarnation and are saved conditionally, an
unforced approval ignores a report of another incarnation, and a purge
drops the cached verdict (FR-014a). Tool-call history from proxied and
code_execution calls is bound to the incarnation the call started on.

REST: generation and incarnation on server payloads; optional
expected_incarnation on tools/approve, tools/block, tools/{tool}/enabled,
enable_all, disable_all, unquarantine and security/approve; 409
server_replaced and 404 for an unknown server. The Web UI sends the
incarnation of the review or server it rendered. A qa_hooks-only
'debug purge-report' / 'debug seed-purge-set' command supports the live
checks.
REST API (removal and incarnation, expected_incarnation, 409
server_replaced), architecture (purge set in the commit, incarnation-bound
approvals), security quarantine (re-add starts clean), CLAUDE.md recent
changes, and the Spec 116 plan and tasks marking what PR2 ships and what
moves to a PR2b (the OAuth credential binding).
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: a6591ab
Status: ✅  Deploy successful!
Preview URL: https://8135eaa5.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-116-pr2-atomic-removal.mcpproxy-docs.pages.dev

View logs

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/116-pr2-atomic-removal

Available Artifacts

  • archive-darwin-amd64 (31 MB)
  • archive-darwin-arm64 (28 MB)
  • archive-linux-amd64 (19 MB)
  • archive-linux-arm64 (17 MB)
  • archive-windows-amd64 (31 MB)
  • archive-windows-arm64 (28 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (27 MB)
  • installer-dmg-darwin-arm64 (25 MB)
  • smart-mcp-proxymcpproxy-goJF383P.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 38312413118 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

…ool listings, unbound writes refused after commit, scanner incarnation carry-through

- GetServerReview and GetAllServers (StateView and legacy paths) read the
  displayed config and the generation/incarnation from one committed snapshot.
- Replay captures the incarnation before resolving the client and rejects a
  server with no committed identity.
- Storage refuses unbound (incarnation 0) tool-call, scan-job and scan-report
  writes once a config commit exists.
- Scanner: Pass 2 and its failed placeholder carry the originating job's
  incarnation (skipped when no longer current); StartScan refuses a server the
  committed view does not know (REST 404); every save goes through the
  conditional writer; the purge observer detaches an obsolete active job.
- REST tool listings carry the incarnation read before their rows
  (incarnation, server_incarnation); the Web UI binds global Tools batch
  actions and Server Detail decisions to the rows on screen and refuses stale
  rows; the review screen shows failed per-tool decisions on every server.
- Spec: #1562 qualified (at-rest half in PR2, in-flight OAuth saves in PR2b).
… stored row, in-commit refresh/exemption clears, identity-backed call history, scan registration and stale-job cleanup bound to the incarnation, Web UI choices and selections bound to the incarnation
…dexes, incarnation check on no-op bulk toggles, incarnation-bound scan summary cache and observer predicate, OAuth PR2b limitation disclosed
…gate's record, removed server's active scan not inherited by a same-name replacement, unbound unquarantine bound at arrival, missing-server 404 on unquarantine and security/approve
…te identity-checked once the original call's server was removed, late toggle rollbacks bound to the review on screen
…record (same-config re-add reuses the identity hash), tool listings serve the StateView only at the committed incarnation and reconcile never carries tools across an incarnation
…rom a lagging view (404 / omitted), tool listings return rows only when the incarnation did not move across the read, removal docs qualify re-add behavior by admission policy
…w only at the bound incarnation, the global listing applies the quarantine exclusion to the replacement it served, the Web UI joins hold evidence only across matching incarnations, the purge oracle finds orphaned history buckets
…rge also clears tool_baseline_decisions marker; incarnation-bound CommitServerApprovalAt)
@Dumbris
Dumbris merged commit fb1ad33 into main Oct 12, 2026
51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: server removal is not atomic with the config commit; a pending removal can be resurrected by any later save

2 participants