Skip to content

fix(config): persisted-vs-effective split — never persist serve flags, MCPPROXY_* env or the env API key - #1302

Merged
Dumbris merged 19 commits into
mainfrom
claude/jovial-sutherland-f77a11
Sep 18, 2026
Merged

Dumbris merged 19 commits into
mainfrom
claude/jovial-sutherland-f77a11

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 18, 2026

Copy link
Copy Markdown
Member

Stacked on #1299 (base branch claude/confident-panini-c552d5; retarget to main once that merges).

Problem

#1299 fixed serve's own three saves. Two other persist paths still marshalled the live config, which carries every process-only override — serve flags (--listen, --read-only, --tool-response-mode, …), the MCPPROXY_* env overrides the loader applies, and the MCPPROXY_API_KEY that Validate/EnsureAPIKey fold into api_key:

  • telemetry.persistConfigLocked (first-run anonymous_id, semver builds only)
  • Runtime.SaveConfiguration (every API-driven server add/enable/quarantine decision) and ApplyConfig

So serve --listen :0 (or MCPPROXY_LISTEN, or the env API key) landed in mcp_config.json the first time a server was enabled from the Web UI.

Design: persisted vs effective

A process-wide override registry in internal/config/process_overrides.go:

  • OverrideForProcess(cfg, Field, source, value) sets the effective value and records (field, source, process value, file value at load). The loader routes every MCPPROXY_* override through it (rebuilt atomically per load), Validate/EnsureAPIKey the env API key, and cmd/mcpproxy every serve flag (loadConfig, applyServeLoggingFlags, applyServeRuntimeFlags). A flag shadows an env record of the same field.
  • PersistableConfig(effective, path) restores the current file value for every field whose effective value still equals its override. config.SaveConfig applies it centrally, so runtime, telemetry, the server-edition admin handlers and the load-modify-save CLI subcommands are all covered.
  • SaveConfigWithEdits(cfg, mergeBase, path) is the save that persists an API edit: overridden fields that moved relative to the merge base (the desired config) are the caller's edits and are written as-is — whatever they moved to, including the flag's own value — so the UI's restart-gated listen edits, the tray's port-conflict SetListenAddress, toggling a field away from and back to its flag value, and "cancel the pending change" all still persist. Records are never removed, so a concurrent save of the still-live config keeps restoring the file value (no window in which MCPPROXY_API_KEY is unprotected).
  • Every in-process save's read-base→write is serialised under one mutex, so a save always reads the file the previous save wrote.
  • ReloadConfiguration re-applies the serve flags onto the pinned running config (the loader only re-applies env), skips a flag the API had superseded, republishes when it differs from the file, feeds the running config to the component side effects (parity with ApplyConfig), and keeps the desired config = file's restart-gated fields + hot flags so GET→PUT round trips after a reload neither leak nor flip a flag.
  • The config watcher's self-write markers and memory-vs-disk comparison use the persistable form, so the daemon's own saves are not misread as external edits.

Known limitation (documented on PersistableConfigWithEdits): an edit that sets an overridden field to exactly the override's value while the merge base already holds it is indistinguishable from a round trip — unreachable from the Web UI, which already shows that value.

Tests

Failing-first tests for each path: internal/telemetry/process_overrides_persist_test.go, internal/runtime/process_overrides_persist_test.go (SaveConfiguration, ApplyConfig round trip / edit / toggle-back / cancel / failed save, reload × flags, watcher, component parity), internal/config/process_overrides_test.go (registry, nested copy-on-write, env batch atomicity, stacked env+flag, concurrent stale save, read/write serialisation), cmd/mcpproxy/serve_flag_persistence_test.go (every serve flag registered; API edit of a flagged field persists).

  • go test -race ./internal/runtime/... ./internal/telemetry/... ./internal/config/... ./cmd/mcpproxy
  • ./scripts/test-api-e2e.sh 65/65 ✅ (run as a scratch copy with the blanket pkill -f "mcpproxy.*serve" on line 80 removed, LISTEN_PORT=18471, so the live tray core survived)
  • golangci-lint v2.6.2 with .github/.golangci.yml: 0 issues ✅

Cross-model review

10 fix→re-review rounds per the CLAUDE.md cap. Rounds 1–2 via opencode github-copilot/gpt-5.6-sol --variant high; from round 3 every Copilot model via opencode reported "exceeded your monthly quota", so rounds 3–10 ran through codex exec -m gpt-5.6-sol (the documented fallback). Round 10 verdict: CLEAN. Each round's finding is recorded in the corresponding commit message; one finding was rejected ("env value changes between loads" — a process's environment is fixed).

Follow-ups (not in this PR)

  • Pre-existing data race: cmd/mcpproxy recordStartupOutcome and telemetry.MaybePrintFirstRunNotice mutate cfg.Telemetry without the telemetry service's mutex while its goroutine reads it. Needs a locked mutator on telemetry.Service plumbed through servermain.
  • Telemetry still saves its whole (possibly stale) config copy; the residual window documented in telemetry.persistConfig for unrelated fields remains, though an overridden field is now always restored from the file.

🤖 Generated with Claude Code

Dumbris and others added 17 commits September 18, 2026 06:08
`serve` saves the config at three sites (auto-generated api_key, first-run
telemetry notice, recordStartupOutcome) and wrote the in-memory cfg, which
already carried every CLI flag override. `serve --listen :0` therefore
wrote `"listen": ":0"` into mcp_config.json and the next unflagged start
(or the tray-launched core) silently booted in stdio mode.

loadConfig now snapshots the file-loaded config before any flag override
and returns a serveConfigSaver; its save() writes that snapshot plus only
the runtime-generated fields (api_key, telemetry). All three save sites
use it, and recordStartupOutcome takes the save func as a parameter. The
snapshot precedes runServer's own overrides too, so --read-only,
--log-level, --disable-management etc. no longer leak either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…PI key

Cross-review round 1 (gpt-5.6-sol): the fatal-serve-error save can fire
hours after startup and would have written the startup-era snapshot over
servers the runtime persisted since; and Validate() copies
MCPPROXY_API_KEY into cfg.APIKey, so every save wrote that secret to disk.

serveConfigSaver.save now re-reads the file as its base (snapshot only if
the file is unreadable), takes api_key from the raw file, and overlays
only the key serve generated itself plus cfg.Telemetry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cross-review round 2 (gpt-5.6-sol): config.LoadFromFile is not a read —
it applies MCPPROXY_* env overrides, copies MCPPROXY_API_KEY into api_key
via Validate, creates data_dir and replaces the process-global registry
list; the fallback path dropped a configured key when the file vanished;
and a startup-generated key overrode a key rotated later via the API.

The base is now DefaultConfig + json.Unmarshal of the file (snapshot of
the same at startup as fallback), and the generated key only fills an
empty api_key. This also stops env overrides leaking on the serve path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cross-review round 3 (gpt-5.6-sol): a bare json.Unmarshal skipped the
read-time normalizations loadConfigFile applies (legacy "teams" →
"server_edition", created stamps), so a serve-time save erased a legacy
teams block. Export config.ReadFile — DefaultConfig + loadConfigFile,
nothing else — and use it as the saver's base so it stays in lockstep
with the loader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cross-review round 4 (gpt-5.6-sol): without --config, config.Load()
discovers ./mcp_config.json or the home file but discarded the path, and
runServer re-derived <data_dir>/mcp_config.json — a possibly different,
never-loaded file that the merge would read as its base.

Add config.LoadWithPath (Load keeps its signature) reporting the file it
read or created, absolute; the saver carries that path and runServer uses
it for every save and for the runtime's config path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestNoDoorPublishesARawServerLeaf keys config-returning functions by
bare name and requires unanimity; a config.ReadFile that returns
*Config tainted every os.ReadFile call in the scanned trees (flagged
internal/tray/managers.go:loadIcon). All five red CI jobs were this
one test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-only overrides

Every persist path saved the LIVE config — the file plus the `serve` CLI
flags, the MCPPROXY_* env overrides and the MCPPROXY_API_KEY that Validate
folds into api_key. PR #1299 fixed serve's own three saves; the runtime's
SaveConfiguration (every API-driven server change) and telemetry's first-run
anonymous_id write still marshalled the whole effective config, so
`serve --listen :0` (or MCPPROXY_LISTEN, or the env API key) landed in
mcp_config.json the first time a server was enabled from the Web UI.

The fix is a process-wide override registry in internal/config:

- OverrideForProcess(cfg, Field, source, value) sets the effective value and
  records (field, process value, file value at load). The loader routes
  every MCPPROXY_* override through it, Validate the env API key, and
  cmd/mcpproxy every serve flag (loadConfig + the new applyServeLoggingFlags /
  applyServeRuntimeFlags).
- PersistableConfig(effective, path) restores the file's current value for
  every field whose effective value STILL equals its override. A field
  edited since (the Settings page changing listen, the tray picking an
  alternate port, an API toggle of read-only) no longer matches and is
  persisted as the edit it is — which keeps the restart-gated listen flow
  working, where a blanket "restore the file value" would have thrown the
  edit away.
- SaveConfig applies it centrally, so runtime, telemetry, the server
  edition's admin handlers and the load-modify-save CLI subcommands are all
  covered. The runtime additionally computes the persistable form for its
  config-watcher self-write markers and the watcher's memory-vs-disk
  comparison, otherwise its own saves would read as external edits and
  reload the file over the hot overrides.

Env-sourced entries are rebuilt on every load (a reload reflects the
variables set now); flag entries survive reloads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gistry

- EnsureAPIKey let MCPPROXY_API_KEY replace a key the FILE holds without
  recording it, so the next runtime/telemetry save wrote the env secret over
  the file key. Recorded as an env override (only when it actually differs,
  so Validate's record keeps the real file value).
- The registry now keys entries by (field, source): a field overridden by
  env AND a flag keeps both records, and a reload — which rebuilds the env
  set — no longer drops the flag record and turns its value into "an edit".
  A flag layered over an env override inherits the env record's file value
  as its fallback.
- The loader rebuilds the env entries as ONE registry update
  (envOverrideBatch.commit) instead of clear-then-add under separate locks,
  so a save on another goroutine can never observe an empty registry
  mid-reload and persist the overrides.
- ReapplyFlagOverrides re-layers the serve flags onto a freshly reloaded
  config (configsvc.ReloadFromFile and the legacy fallback); the loader only
  re-applies env, so a hand edit of an unrelated key used to switch
  --read-only / --tool-response-mode off.
- Documented the inherent limitation: an explicit edit that sets an
  overridden field to exactly the override's value is indistinguishable from
  a round trip (unreachable from the Web UI, which already shows that value).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng config only

Review round 2:

- An override the running config no longer carries was superseded by an
  API edit (a hot apply, UpdateListenAddress). It is now retired
  (RetireSupersededOverrides, called from ApplyConfig with what the process
  adopted and from SaveConfiguration with the live config), so a reload no
  longer resurrects the flag over the edit, and — the sharper case — an edit
  BACK to the override's value persists as the edit it is instead of being
  swapped for the file value.
- ReapplyFlagOverrides(cfg, live) runs on the runtime's pinned live copy, not
  inside configsvc.ReloadFromFile: the desired config stays the file, so a
  pending file edit of a restart-gated flag field (listen) is still reported
  as pending and not clobbered by a later save. The pinned config is
  republished whenever it differs from the raw file.
- A repeated registration of the same override (loadConfig and runServer
  both applied --tool-response-limit) inherits the previous record's file
  value; the duplicate registration is also removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…raw file

Review round 3 (Sol, partial — quota): after republishing the pinned +
flag-reapplied config, ReloadConfiguration kept feeding the RAW file
snapshot to the upstream manager, applyComponentConfigLocked (truncator,
logging), telemetry and the update checker — so a hot reload rebuilt the
truncator from the file's tool_response_limit while r.cfg said the flag's.
Parity with ApplyConfig, which applies hotCfg: every side effect now takes
the running config; the restart-required warning still diffs the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… saved

Review round 4 (codex gpt-5.6-sol; the opencode Copilot models are out of
monthly quota):

- A flag shadows an env override of the same field, so only that winner may
  decide whether the field was edited. The shadowed env record used to
  intercept an API edit that happened to equal the env value and restore
  the file value instead. PersistableConfig and RetireSupersededOverrides
  now resolve one effective override per field; a superseded field forgets
  its whole stack (the loader re-records env on the next reload).
- ApplyConfig retires against what the API SAVED (newCfg), not the pinned
  hotCfg: editing listen under --listen ends that override for this process
  even though the listener stays bound, so an edit back to the flag's value
  ("cancel the pending change") is persisted as asked — disk, the desired
  config and the API result agree again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…re only edited fields

Review round 5 (codex gpt-5.6-sol): after a disk reload the desired config
was the raw file, so GET /config showed read_only_mode=false under
--read-only and any unrelated PUT round-tripped that value back — retiring
the flag and hot-applying the file's value.

- ReloadConfiguration's desired config is now pinRestartGated(fileCfg,
  pinned): the file's restart-gated fields (a pending listen edit stays
  pending) with the hot flags riding along, exactly like the startup
  desired config.
- ApplyConfig retires an override only when the apply MOVED the field
  relative to its merge base (RetireEditedOverrides(baseCfg, newCfg)); a
  round trip of a value the base already held is not an edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lone

Review round 6 (codex gpt-5.6-sol): after a reload the desired listen is
the file's, so an API edit setting it to the flag's own address is a
distinguishable edit (base != next == override) — but retirement ran after
the save and required the value to differ from the override, so
PersistableConfig swapped the edit for the file value and the override
stayed. RetireEditedOverrides now retires every field the apply moved
relative to its merge base, whatever it moved to, and ApplyConfig calls it
before config.SaveConfig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review round 7 (codex gpt-5.6-sol): retirement precedes the save, so a
transient write failure left the field retired while the live config still
carried the override — the next unrelated save would have written it, for
MCPPROXY_API_KEY the secret, into the file. RetireEditedOverrides returns
what it removed; ApplyConfig restores it on the save error path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es it instead

Review round 8 (codex gpt-5.6-sol): removing a record on an API edit
opened a window in which a concurrent save of the still-live config
(telemetry) had no protection and could write MCPPROXY_API_KEY to disk.

Retirement is gone. The save that persists an API edit is
SaveConfigWithEdits(cfg, mergeBase, path): the overridden fields that MOVED
between the merge base (the desired config) and the saved config are the
caller's edits and are written as they are, whatever they moved to; every
other save keeps restoring the CURRENT file value — which after the edit's
save is the edit itself. No mutable state, no window. ApplyConfig uses it
and marks its self-write with the same bytes; ReapplyFlagOverrides skips
(keeps) a flag the live config superseded. All earlier scenarios (toggle
back, cancel a pending listen edit, edit to the flag's own value after a
reload, failed save) are re-expressed as tests against the new seam, plus
a concurrent stale-save test for the api_key leak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review round 9 (codex gpt-5.6-sol): a save reads the file as its base and
writes a whole replacement, so an API edit landing between another save's
read and write was reverted (the residual window telemetry.persistConfig
documents, widened by the base read). SaveConfigWithEdits now holds one
package mutex across the read and the write, so in-process savers — the
runtime, telemetry, serve's own saves — always read the file the previous
save wrote. A test hook between the two steps pins the schedule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 18, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 577dda5
Status: ✅  Deploy successful!
Preview URL: https://5a467df5.mcpproxy-docs.pages.dev
Branch Preview URL: https://claude-jovial-sutherland-f77.mcpproxy-docs.pages.dev

View logs

@Dumbris
Dumbris changed the base branch from claude/confident-panini-c552d5 to main September 18, 2026 12:42
# Conflicts:
#	cmd/mcpproxy/main.go
#	cmd/mcpproxy/serve_flag_persistence_test.go
#	internal/runtime/lifecycle.go
@codecov-commenter

codecov-commenter commented Sep 18, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 82.02247% with 64 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/config/process_overrides.go 82.88% 25 Missing and 7 partials ⚠️
cmd/mcpproxy/main.go 64.91% 15 Missing and 5 partials ⚠️
internal/runtime/lifecycle.go 70.37% 7 Missing and 1 partial ⚠️
internal/runtime/config_watcher.go 77.77% 2 Missing ⚠️
internal/truncate/truncator.go 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

…indows-safe

Follow-up to cb35775 (the main merge-conflict resolution for this PR).

The test simulated a failed atomic config save by os.Chmod'ing the config
directory to 0o500. That works on POSIX but Windows does not enforce
Unix-style directory permission bits via os.Chmod the same way, so the
GitHub Actions windows-latest runner could still write into the
"read-only" directory and the save silently succeeded, failing the test
with "An error is expected but got nil".

Replace it with a mechanism that fails identically on every OS: point the
save at a path whose parent is a plain file instead of a directory.
writeConfigFile's os.MkdirAll(dir, 0700) pre-flight does a pure-Go
`Stat(dir); if err == nil && !IsDir() { return ENOTDIR }` check before any
OS-specific mkdir syscall, so this induces the same real write failure on
Linux, macOS and Windows. The broken path is scoped to this one
ApplyConfig call only (the runtime's own cfgPath stays a real writable
directory), so the later SaveConfiguration() still exercises "disk
recovered" for real.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: claude/jovial-sutherland-f77a11

Available Artifacts

  • archive-darwin-amd64 (30 MB)
  • archive-darwin-arm64 (27 MB)
  • archive-linux-amd64 (18 MB)
  • archive-linux-arm64 (16 MB)
  • archive-windows-amd64 (30 MB)
  • archive-windows-arm64 (26 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (24 MB)
  • installer-dmg-darwin-arm64 (21 MB)
  • smart-mcp-proxymcpproxy-go529BBO.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 35349239459 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@Dumbris
Dumbris merged commit 673e83c into main Sep 18, 2026
41 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.

2 participants