Skip to content

fix: single config commit section with per-server generations (Spec 116 PR1; #1558, #1560) - #1590

Merged
Dumbris merged 22 commits into
mainfrom
fix/116-pr1-config-commit
Oct 12, 2026
Merged

Dumbris merged 22 commits into
mainfrom
fix/116-pr1-config-commit

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 11, 2026

Copy link
Copy Markdown
Member

Scope

Spec 116 PR1 only (specs/116-config-commit-consistency/plan.md §PR split, tasks T001-T034). This PR also ships the spec directory, so the spec PR #1583 can be closed.

Every writer of the server set or of a server's fields now goes through one primitive, Runtime.Commit(reason, fn) (internal/runtime/commit.go). It holds configCommitMu from reading the live config until the new config is published, and runs one pipeline:

  1. Build: clone the live config; fn mutates the copy through CommitTx (CreateServer, UpdateServer, SetEnabled, SetQuarantined, RemoveServer, ReplaceConfig, ReconcileLive, MutateGlobal, ExpectGeneration/ExpectIncarnation, UnderApprovalLock, Bootstrap, ForceDisk).
  2. Gate: the security: servers loaded from mcp_config.json bypass the trust-mode admission gate entirely #937 admission gate against the stored view. The configsvc pre-publish hook is removed.
  3. Prepare: configsvc.Prepare assigns the commit generation G and the per-server generations (commitview.ServerGen{Gen, Incarnation}), and computes the change set: Created, Updated (with Reconnect), Removed, or Replaced for a same-commit remove and re-create. It also computes the projection diff against storage.
  4. Locks: takes the approval lock of every changed server, sorted, then runs the UnderApprovalLock hooks. A change to quarantine_enabled takes every server's lock (FR-008c).
  5. Disk: captures the raw file (bytes, mode, or absence), then writes the new file with its self-write marker.
  6. Storage: one bbolt transaction (ApplyServerCommit). It writes rows with their prepared stamps verbatim, the deletions (row and call history) and meta.config_commit_generation/quarantine_enabled. Then the index purge for removed, newly quarantined and newly disabled servers (B1/security: quarantining a server via the config file leaves its tools in the search index, so retrieve_tools still discloses their descriptions #1061), the OAuth-config clear and the orphan GC. On a storage failure the captured file is restored byte for byte and nothing is published.
  7. Advance: Manager.RestampRetained gives every retained instance its new config and generation as one atomic pair (managed.Client.SetConfigAt, forward only).
  8. Publish: configsvc.Publish and the legacy r.cfg/desiredCfg swap.

After the lock is released, the commit unregisters removed servers, spawns connect workers stamped with the generation for created, replaced and reconnecting servers, requests discovery for restamped connected servers, and sends its events once.

Converted writers: ApplyConfig, ReloadConfiguration (one pinned publish; the raw file is never published), the boot reconcile (ReconcileLive + Bootstrap), gateInitialConfig, UpdateConfig, UpdateListenAddress, SaveConfiguration (persists the live config and no longer rebuilds it from storage), EnableServer, BulkEnableServers, QuarantineServer, the RestartServer disk refresh and SetServerShared. REST add, update, PATCH and remove, MCP upstream_servers add, update, patch and remove, and MCP quarantine_security quarantine_server are converted too, along with registry source add, edit and remove and the config funnel (MutateConfig).

D15/D16 from the design reviews:

  • A discovery pass makes its final eligibility check (stored row present, enabled, not quarantined, generation equal to the instance's) and all of its index and approval writes in one hold of the server's approval lock.
  • Approval policy is resolved from storage (row and meta.quarantine_enabled), not from the published config.

scripts/check-upstream-write-sites.sh (with a red/green self-test) runs in CI. It fails on any upstreams row write outside internal/storage and internal/runtime/commit*.go.

Not in this PR (as the plan says): the full purge set and incarnation-bound approvals (PR2), generation checks in workers and the supervisor (PR3), and dial gates and retirement (PR4). approveBaselineToolsForServerAt(name, inc) is the hook PR2 fills.

Design decisions and deviations from the spec

  • The config funnel runs inside the commit (not in the plan). Live L1 showed that MutateConfig (PATCH /api/v1/config, profile writes) read the desired config before the lock and applied a whole copy of it. That resurrected removed servers and reverted quarantines even with every other writer converted. Its read, mutation, binding guard and pin move now run inside the apply commit (applyConfigWith). The regression test is TestCommit_FunnelMutationCannotResurrectRemoval, which fails on the previous commit. Lock order: bindingWriteMu → configCommitMu.
  • Removal deletes what each path deleted before, now synchronously and inside the commit: the row and call history, the index, and the orphan GC. The REST removal path still clears OAuth state, the refresh schedule and tool approvals after the commit, as before. PR2 moves the full purge set into the transaction.
  • Removed and replaced servers are unregistered from the manager after the lock is released, synchronously on the caller's goroutine, so the call returns with the server gone. AdvanceGenerations (PR3) moves this into step 7.
  • Instances built by the supervisor or legacy paths have generation 0. D15 skips only the generation comparison for them. The row checks (present, enabled, not quarantined) still apply. PR3 stamps every constructor.
  • Storage is authoritative only after a first commit. D15 and D16 read storage only once meta.config_commit_generation exists. Before that (unit-test fixtures that never commit), the published config is used, as on main. In the daemon the startup admission commit writes the key before anything else runs.
  • Step 2 does not run ValidateDetailed over the whole config. ApplyConfig and the funnel validate the submitted config, and the REST and MCP doors validate their inputs as before. Validating every commit would let one invalid unrelated entry block all writes.
  • The startup admission gate is its own commit (no disk write, no workers), so admission is still settled before supervisor.Start(). The boot commit then runs with Bootstrap(). Rows that match the file keep their stored stamps (Prepare seed). Neither commit rewrites the operator's file, and ReloadConfiguration never writes it either.
  • A create still refuses a name that only storage holds (fix: make server add create-only across REST, MCP and Web UI (UX-01) #1570 contract, TestHandleAddUpstream_DuplicateNameIsRefusedWithoutSideEffects).
  • REST PATCH resolution moved into resolveServerPatch, which runs inside the commit via the new optional httpapi.ServerPatcher interface (Server.PatchServer). Test doubles that lack it use the old path. The server's UpdateServer now also applies expose_prompts, max_concurrent_requests, queue_size and queue_timeout. The PATCH handler already resolved these fields, but on main they were dropped before persisting.
  • Bare MCP proxy. A runtime-less MCPProxyServer exists only in package tests. Its storage writer lives in bare_upstream_writer_test.go, so production has no write path outside the commit.
  • Test-only edits. The pre-Spec-116 LoadConfiguredServers(cfg) is deleted (D14). The runtime package's tests get a test-only shim, and internal/server tests call ReconcileServers/CommitConfig instead. One configsvc test drops the removed pre-publish hook. server_sharing_test replaces the removed admission re-publication with a commit. The quarantine_security list_quarantined returns env/headers/oauth secrets in plaintext, unauthenticated #1148 door-scan tables get two rows (prepareLocked, mergeUpstreamPatch). No assertion of the fix: make server add create-only across REST, MCP and Web UI (UX-01) #1570, fix: serialize per-server tool-approval writes so baselines and approvals are complete (UX-02) #1571, security: servers loaded from mcp_config.json bypass the trust-mode admission gate entirely #937 or security: quarantining a server via the config file leaves its tools in the search index, so retrieve_tools still discloses their descriptions #1061 tests changed. create_only_add_test calls removeServerFromConfig instead of the deleted removeServerStorage.
  • Re-entrant Commit on the same goroutine returns an invariant error instead of deadlocking. It uses a goroutine-id check, always on rather than behind a build tag.
  • Out-of-scope cleanup carried in this PR: CopyServerConfig now copies ToonOutput, with a reflection-style test that every serialized field survives the copy.

Lock order (normative, plan §Lock order)

configCommitMu → server approval locks (sorted; released before step 7) → storage.Manager.mu/bbolt, and → bleve deletes. configCommitMu → upstream.Manager.captureMu (W) → Manager.mu in step 7. configCommitMu → configsvc.updateMu → Runtime.mu. bindingWriteMu → configCommitMu (funnel). Discovery: captureSnapMu → approval lock(one) → storage, index. Nothing that holds an inner lock calls Commit. TestLockOrder_PR1 covers re-entry, commit vs discovery, and commit vs a capture section.

Compatibility

  • No new dependencies. No REST, MCP or CLI contract changes: response shapes and status codes are unchanged, and enable/disable of an unknown server is still 500, which test-api-e2e.sh pins.
  • Additive bbolt fields (UpstreamRecord.generation/incarnation, meta keys); no migration.
  • Effects now hold when a call returns instead of eventually: storage, file and index agree before the response.

Verification

  • Failing-first on the parent (seams and tests only, commit 3313465): T1.1 (both variants), T1.2, T1.3, T1.4, T1.5, T1.6, T1.7 and T1.12 all fail on main and pass here.

  • New tests: T1.9, T1.11, T1.13-T1.21, TestLockOrder_PR1, the configsvc generation table, the ApplyServerCommit all-or-nothing and stamp tests, TestCommitviewIsLeaf, and the funnel resurrection test.

  • go test -race -count=20 of the runtime commit tests at GOMAXPROCS=1 and 4, and -count=10 of the server T1 tests: green.

  • go test -race -tags server -skip "E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint" over runtime, server, httpapi, storage, oauth, config, commitview and upstream: green. The full non-race ./internal/... ./cmd/... run is green.

  • golangci-lint v2, bare and --build-tags server: no finding in a changed file.

  • ./scripts/test-api-e2e.sh on a scratch port: 68 passed, 0 failed.

  • Live L1 on scratch qa070 daemons with the big profile: 40 concurrent REST and MCP writers, removals, quarantines, a 50x DELETE→POST cycle, and config PATCHes.

    Check main This PR
    Removed servers resurrected 4/5, still present after restart 0
    DELETE 200 → POST 409 47/50 0/50
    Reverted quarantines none in that run none
    config.json == API == config.db, after settle and after restart yes yes
    B1: retrieve_tools hits right after MCP quarantine_server 20 0
    Stamps changed by a restart n/a 0 of 26

    trusted-library reconnects within about 2 s of a restart.

  • Not run live: the forward-header revoke on a connected HTTP server (L1 step 4). T1.19 covers it at instance level.

Fixes #1558
Fixes #1560
Refs #1559
Supersedes #1583 (spec)

🤖 Generated with Claude Code

…16 PR1)

Every writer of the server set or a server's fields goes through
Runtime.Commit: one configCommitMu section that gates, prepares
generations, writes disk and one bbolt transaction, restamps retained
instances and publishes. SaveConfiguration persists the live config
instead of rebuilding it from storage; REST PATCH and MCP update/patch
resolve inside the commit; the MCP quarantine path purges the index (B1);
discovery checks eligibility and writes the index under the approval
lock (D15) and reads approval policy from storage (D16). Adds
scripts/check-upstream-write-sites.sh to CI.
MutateConfig (PATCH /api/v1/config, profile writes) built its candidate
from the desired config read before the commit lock, so its apply could
revert a removal or quarantine committed in between. The read, mutation,
guard and pin move now run inside the apply commit.
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: ea6832d
Status: ✅  Deploy successful!
Preview URL: https://fcf5bad3.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-116-pr1-config-commit.mcpproxy-docs.pages.dev

View logs

… D15 hold, reconnect predicate)

- A commit that cannot read the stored view aborts with nothing written
  (the boot commit continues without storage, as main's boot sync did).
- The restart refresh imports the server from the file without rewriting it.
- The no-op skip path records the caller's desired config, so a reload that
  cancels a pending restart-gated change is not undone by the next write.
- Every published generation is written to meta.config_commit_generation,
  global-only commits included, so generations stay monotonic across restarts.
- upstream.Manager.AddServerConfigAt replaces an instance exactly when
  config.ConnectionEquivalent is false (isolation, OAuth, working_dir and init
  timeout patches reconnect again).
- A registry URL edit copies only the probe-derived fields onto the live entry.
- Compensation restores the captured file mode exactly (umask-independent,
  0000 included).
- D15: a discovery pass's last-good snapshot and StateView publish run in the
  approval-lock hold after its eligibility/generation check; the sweep
  publishes per server.
- Tests: failing-first regressions for each fix, an in-transaction rollback
  case for ApplyServerCommit, a connected-upstream retained-instance test,
  Windows-safe mode assertion. plan.md and quickstart.md amended.
@codecov-commenter

codecov-commenter commented Oct 11, 2026 •

Copy link
Copy Markdown

…ation-checked review capture and empty stamp, once-per-commit events)

- REST PATCH validates max_concurrent_requests/queue_size/queue_timeout inside
  the commit against the live server_concurrency_defaults
  (Config.ValidateServerConcurrency); a value the next start would refuse is
  HTTP 400 with live, storage and file unchanged.
- FR-008a: the review capture of an ineligible server and the empty-result
  ToolsDiscovered stamp run in one approval-lock hold behind a row/generation
  check, so a stale tools/list cannot become the review snapshot or mark
  discovery complete after a connection-changing commit. The inspection
  capture binds to the instance generation taken before its list.
- FR-028: CommitTx.EventExtra carries the caller's fields on the commit's one
  servers.changed; QuarantineServer, EnableServer and BulkEnableServers no
  longer send a second copy (or a second review.changed).
- check-upstream-write-sites.sh covers the AsyncManager queue writers, with
  planted self-tests.
- T1.21 checks the operator's noncanonical bytes before and after each boot.
- Spec: FR-008a, contracts/surfaces.md (PATCH 400, additive event payloads),
  quickstart L1.4 reads generations from config.db, createMu note in
  plan/tasks.
…uth clear in the row transaction, UpdateConfig path published with the commit, failed-reload recovery docs)
…proval lock, path-only UpdateConfig writes the new path, AddUpstream in the write-site check, compensation and rename docs)
… every branch, deep-copied commit working copy, step 4 lock set and reload disk/compensation docs)
…ide the commit, MCP update reports only the committed instance's connection, D5/plan Prepare and lock-set docs)
… the current instance, unchanged MCP update waits for the committed instance, write-site check catches method values, admission step docs)
…he commit lock, startup admission commit keeps the empty-set GC guard, write-site check catches split selectors, unique FR-009a/FR-010a)
…nts instead of skipping lines starting with *, contract documents PATCH /api/v1/config and no PUT server route)
… comments between the dot and the method name, research.md names POST /api/v1/config/apply instead of PUT/apply)
… r11)

plan.md said the goroutine-id re-entry check in Runtime.Commit was compiled
only under a commitlockcheck build tag. No such tag exists; the check runs
unconditionally via runtime.Stack and returns errCommitReentry.
… commit generation order, UnderApprovalLock lock-hold and hook-error tests, write-site check exits 2 on scan failures, D5 and data-model docs match the conditional disk step and commitOwner guard)
Conflicts in internal/server/server.go and internal/runtime/lifecycle.go
(#1561 PATCH quarantine purge) resolved to the commit-based UpdateServer.
The commit's index purge now also covers an explicit quarantine decision on
an entry the admission gate had quarantined implicitly, so main's
TestUpdateServer_QuarantineTrue_PurgesIndexedTools passes without the
separate PurgeQuarantinedServerFromIndex seam.
…es an explicit PATCH quarantine of a gate-quarantined server

UpdateConfig (no production caller left) schedules no manager work, as
before Spec 116: its connect worker raced the supervisor's reconnect of the
same server and left the replacement instance disconnected, failing
TestCaptureQuarantinedToolDefinitions_RelistsAfterClientReplacement about
1 run in 10 under -race. The approve-with-blocks fixture configures the
counting upstream's transport, so the unquarantine commit's connect worker
re-adds a server that can connect (TestApproveWithBlockNeverDispatchesBlockedTool).
An UpdateServer/SetQuarantined that makes an explicit quarantine decision
marks the server for the index purge even when the admission gate had
already quarantined it implicitly (#1561, merged from main); whole-config
replacements do not count as decisions.
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/116-pr1-config-commit

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-goWOUQLO.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 38239075255 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

…t quarantine

Sol round 12 (#1061, #1561): an UpdateServer or SetQuarantined that leaves an
entry explicitly quarantined now always marks it for the commit's index purge,
also when the entry already was an explicit quarantine. A commit whose only
effect is such a purge (an identical PATCH {"quarantined":true}, POST
.../quarantine or MCP quarantine_server) no longer takes the skip rule: it
takes the server's approval lock, purges the global index and rebuilds the
affected profile indexes, and publishes nothing. Main's Server.UpdateServer
purged on every explicit true request. FR-001a documents the exception.
…iting the published snapshot in place

TestE2E_ToolResponseModeToggle and TestE2E_DirectDeferredSelfHealingInvalidParams
relied on pre-Spec-116 aliasing: normalizeHarnessConfigForApply wrote tools_limit
and call_tool_timeout into Config() in place, which on main happened to be the
same object GET /api/v1/config serves (desired config). Under the commit design
the desired config is a separate copy, so every /config/apply failed validation
on tools_limit. Leg A of the toggle test also wrote the compact config file
before UpdateConfig registered the path; a path-adopting commit writes the
committed config at the new path (spec edge case "Path change"), clobbering it.

The helper now commits a caller-owned copy through CommitConfig, the toggle test
uses it and registers the path before the hand edit. New
TestE2E_HarnessNormalizeReachesRESTConfig fails on the old helper.
Dumbris added a commit that referenced this pull request Oct 12, 2026
…ct engine (RT-01..RT-07)

Adds the TPA attack lab (Docker-isolated malicious fixtures, red-team harness, experiment suite) and fixes RT-01..RT-07: structural input-schema changes and safety-annotation flips are held for review in every trust mode (versioned hash migration keeps existing approvals); trust_mode scan never auto-approves a changed contract; quarantined-but-connected servers have contract changes verified without indexing; scans cover the contracts discovery ingested (incl. output schemas) and fail closed on lookup errors; detect engine hardening on top of SEC-01 (destination matching, data-sink parameter shapes, schema-text scanning); scanners read a proxy-owned source copy so symlinks cannot redirect the snapshot. Fresh Docker suite: EXP-1..EXP-6c all HOLD (EXP-1 dangerous, EXP-3 dangerous); baseline runner dangerous 93/100; scan-eval recall 1.0 fp 0.0. OpenCode gpt-6.1-sol: SHIP at round 11, rounds 12-21 P2-only (cap extended by the maintainer); remaining P2s tracked in #1601 and #1602. Failing checks: E2E TestE2E_DisableServerRemovesToolsFromSearch also fails on main since #1590 (being fixed separately); CodeQL go/path-injection on operator-configured working_dir paths (non-required, reviewed).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants