fix(mcpinstall): stop destroying user config on install/uninstall - #35
Merged
Conversation
Repair data-loss and clobbering bugs in the MCP installer's read/write adapters and the install/uninstall CLI flows. P0 numeric corruption of ~/.claude.json - internal/mcpinstall/claude.go: decode JSON with json.Decoder + UseNumber so integers above 2^53 round-trip losslessly instead of being coerced to float64 and rewritten rounded (protects .mcp.json and opencode.json too). P0 TOML content destruction (~/.codex/config.toml, ~/.grok/config.toml) - internal/mcpinstall/tomledit.go (new): surgical, format-preserving text edits. Only the [mcp_servers.sting] table and its subtables are inserted/replaced/ removed; comments, ordering, quoting, and all other tables are left untouched. A string/array-aware scanner avoids mistaking '[' in values or multiline strings for headers. A sting entry authored as an inline/dotted key is refused rather than corrupted. - internal/mcpinstall/codex.go, grok.go: WriteEntry/RemoveEntry now use the surgical editor; the comment-destroying writeTOMLDoc remarshal is removed. P1 WriteEntry clobbered user-added keys on upgrade - claude.go, opencode.go: merge into the existing entry map (set command/args/ enabled/type; preserve env, timeout, headers, ...). - grok.go: env and other extra keys are preserved through the surgical merge; removed the dead grokServer.Env field (env is preserved structurally, not via the struct, so the field had no consumer). P1 WriteAtomic durability - internal/mcpinstall/atomic.go: fsync the temp file before rename and fsync the parent directory after rename (dir sync skipped on Windows). P2 fixes - internal/cli/uninstall.go: a present-but-undecodable entry is treated as removable and one bad runtime no longer aborts removal from the others (errors.Join, named per runtime). - internal/cli/install.go: per-runtime error aggregation with errors.Join and the failing runtime named; a deliberately disabled entry keeps enabled=false on reinstall. - opencode.go, grok.go: Detect stats the resolved config dir before returning true, so a stale OPENCODE_CONFIG_DIR/GROK_CONFIG_DIR cannot fabricate a config. - claude.go: a freshly created ~/.claude.json is written 0600 (OAuth material). - claude.go jsonObjectAt: treat an explicit "mcpServers": null as absent. Docs - internal/mcpinstall/runtime.go: correct the package doc to describe the now-honest write semantics (surgical for TOML, structural merge for JSON, atomic with fsync). Adds regression tests covering every behavioral fix (large-int JSON round-trip, TOML comment/env preservation, disabled-state preservation, detection false positives, and CLI error aggregation). Signed-off-by: Shawn Stratton <shawn.stratton@mface.net>
mfacenet
force-pushed
the
fix/mcpinstall-config-fidelity
branch
from
July 11, 2026 19:32
28ed31f to
6e5221a
Compare
mfacenet
marked this pull request as ready for review
July 11, 2026 19:51
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes multiple audit findings where sting install / sting uninstall could corrupt or clobber user config files by improving JSON numeric fidelity, preserving TOML formatting, making writes crash-safe, and making per-runtime operations more resilient.
Changes:
- Add a format-preserving TOML “surgical editor” for Codex/Grok configs and migrate adapters to use it.
- Improve JSON handling (lossless numbers + merge-with-existing semantics) and tighten permissions for newly created
~/.claude.json. - Make atomic writes more crash-safe (fsync temp file + parent dir) and aggregate install/uninstall errors per runtime.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/mcpinstall/tomledit.go | New surgical TOML editor to insert/replace/remove only the mcp_servers.sting subtree without rewriting unrelated bytes. |
| internal/mcpinstall/tomledit_test.go | Regression tests for TOML format preservation, header scanning correctness, and refusal of unsafe inline/dotted entries. |
| internal/mcpinstall/snippet.go | Updates Grok snippet generation to match updated Grok adapter structures. |
| internal/mcpinstall/runtime.go | Package doc updated to reflect atomic+fsync semantics and format-preserving behavior. |
| internal/mcpinstall/opencode.go | Avoid false-positive detection from stale env vars; merge writes to preserve user-added keys. |
| internal/mcpinstall/json_fidelity_test.go | Regression tests for JSON numeric round-tripping, null handling, permission mode, and merge preservation. |
| internal/mcpinstall/grok.go | Avoid false-positive detection; migrate Grok writes/removals to surgical TOML edits; clarify struct intent. |
| internal/mcpinstall/detect_falsepos_test.go | Tests for stale GROK_CONFIG_DIR / OPENCODE_CONFIG_DIR not fabricating detection. |
| internal/mcpinstall/codex.go | Migrate Codex writes/removals to surgical TOML edits; remove remarshal writer. |
| internal/mcpinstall/claude.go | JSON UseNumber() decoding; merge writes to preserve user keys; set newly created file mode to 0600; treat null objects as absent. |
| internal/mcpinstall/atomic.go | Improve atomic write durability via Sync() on temp file and fsync parent directory after rename (non-Windows). |
| internal/cli/uninstall.go | Continue uninstall across runtimes and return aggregated errors; treat undecodable entries as candidates. |
| internal/cli/install.go | Continue install across runtimes and return aggregated errors; preserve user-disabled entries. |
| internal/cli/install_more_test.go | Tests for enabled-state preservation and per-runtime error aggregation behavior. |
Comment on lines
94
to
99
| var srv grokServer | ||
| if err := decodeTOMLInto(raw, &srv, path, "mcp_servers."+serverKey); err != nil { | ||
| return Entry{}, false, err | ||
| } | ||
| return Entry{Command: srv.Command, Args: srv.Args, Enabled: srv.Enabled}, true, nil | ||
| return Entry(srv), true, nil | ||
| } |
Comment on lines
37
to
42
| case "grok": | ||
| return renderTOML(map[string]any{ | ||
| "mcp_servers": map[string]any{ | ||
| serverKey: grokServer{Command: e.Command, Args: e.Args, Enabled: e.Enabled}, | ||
| serverKey: grokServer(e), | ||
| }, | ||
| }) |
Comment on lines
+414
to
+416
| // skipTOMLString advances past the TOML string beginning at raw[i] (a quote | ||
| // char) and returns the index just past its close. Handles basic ("), literal | ||
| // ('), and multiline (""" / ”') strings. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes verified audit findings where
sting install/sting uninstallcouldsilently corrupt or destroy user config files. All findings are listed below
with
file:line(pre-change references), what changed, and any caveats.Verification:
go build ./...,go vet ./...,go test ./..., andgolangci-lint v2.12.2 run ./...all pass. Per-package coverage gate(
scripts/check-coverage.sh) passes;internal/mcpinstallis at 88.4%.P0 — JSON numeric corruption of
~/.claude.jsoninternal/mcpinstall/claude.go:123-156(readJSONDoc)json.Unmarshalintomap[string]anycoerced every number tofloat64, and theClaude user-scope adapter rewrites the entire
~/.claude.json, so any integerabove 2^53 anywhere in the file was silently rounded on install/uninstall.
Change: decode with
json.Decoder+UseNumber()sojson.Numberround-trips losslessly. This also protects
.mcp.jsonandopencode.json.Test:
TestClaudeLargeIntegerRoundTrip(9007199254740993 survives a write).P0 — TOML content destruction (
~/.codex/config.toml,~/.grok/config.toml)internal/mcpinstall/codex.go:116-146(readTOMLDoc/writeTOMLDoc, shared by grok.go)go-toml v2 has no comment model, so the read-then-remarshal cycle deleted all
comments, reordered keys, rewrote quoting, and exploded inline tables.
Change: implemented a surgical, format-preserving text editor
(
internal/mcpinstall/tomledit.go). It locates only the[mcp_servers.sting]table and its
[mcp_servers.sting.*]subtables and inserts/replaces/removes justthose bytes, leaving every other byte untouched. A string- and array-aware
scanner tracks multiline strings and array-bracket depth so a
[inside a valueor multiline string is never mistaken for a table header. The comment-destroying
writeTOMLDocremarshal path is removed;WriteEntry/RemoveEntrynow use thesurgical editor.
ReadEntrystill uses go-toml (read-only, no data loss).go-toml/v2only to render thesmall sting block and to detect malformed files / read existing keys).
Limitations (documented in the file header, and safe):
[mcp_servers.sting]table header — which is howsting and every runtime write it. An entry hand-authored as an inline/dotted
key (
mcp_servers.sting = {...}) is refused with a clear "edit manually"error rather than silently duplicated or corrupted.
untested edge; sting's own tables never produce this.
Tests:
TestCodexWritePreservesComments,TestGrokWritePreservesEnvOnPathChange,TestCodexRemovePreservesNeighborComment,TestScannerIgnoresBracketsInValues,TestScannerHandlesArrayTables,TestInlineStingEntryRefused,TestQuotedStingHeaderMatches,TestSplitTOMLKeyQuoted, and others.P1 — WriteEntry clobbered user-added keys on upgrade
claude.go:94,grok.go:118(grokServer.Env dead field atgrok.go:74),opencode.go:142WriteEntryassigned a fresh struct, so a user entry carryingenv(a token),type,cwd,timeout, orheaderslost those keys whenever the command pathchanged — the exact upgrade path.
Change: merge into the existing entry instead of replacing it.
claude.go/opencode.go: read the existing entry map, setcommand/args/enabled/type, preserve all other keys.
grok.go: env and other extra keys are preserved structurally by the TOMLsurgical merge (reads the existing
[mcp_servers.sting]table, overwrites onlycommand/args/enabled, keeps env and the rest).
(not round-tripped through the struct), the
grokServer.Envfield had noconsumer, so it was removed to resolve the dead-field finding. I did not
add
Envto the sharedEntrybecause that would spuriously break thereflect.DeepEqualidempotency check inrunInstall(install never suppliesenv, so
existing.Env != desired.Envwould report "updated" on every run).Test:
TestGrokWritePreservesEnvOnPathChange,TestClaudeWritePreservesUserKeys,TestOpencodeWritePreservesUserKeys(env survives a path-changing update).P1 — WriteAtomic never fsync'd
internal/mcpinstall/atomic.go:31-48write→chmod→close→rename with no fsync could leave a zero-length/truncated config
after a crash.
Change:
Sync()the temp file beforeClose(), and fsync the parentdirectory after
os.Rename(dir sync guarded/skipped on Windows). The existingsame-dir temp file and mode preservation are unchanged.
P2 — landed
internal/cli/uninstall.go:100-102):a present-but-undecodable entry is treated as removable (RemoveEntry only needs
key existence), and per-runtime errors are collected with
errors.Joinandnamed, so one bad config no longer aborts removal from the others.
Test:
TestRunUninstallMalformedEntryDoesNotAbortOthers.install.go:78-107): continueper-runtime, aggregate with
errors.Join, name the failing runtime.Test:
TestRunInstallAggregatesErrors.opencode.go:37-39,grok.go:32-34): stat theresolved config dir before declaring detection so a stale
OPENCODE_CONFIG_DIR/GROK_CONFIG_DIRcannot fabricate a config tree.Tests:
TestOpencodeDetectStaleEnvVar,TestGrokDetectStaleEnvVar.~/.claude.jsonas 0644 → now 0600 (OAuthmaterial). WriteAtomic still preserves an existing file's mode.
Test:
TestClaudeCreatesPrivateFile."mcpServers": null(claude.go:160-169): explicitnull is now treated as absent. Test:
TestClaudeNullMcpServers.enabled:falseon reinstall (install.go:197/opencode / grok): reinstall no longer forces enabled back to true.
Tests:
TestRunInstallPreservesDisabled,TestGrokWritePreservesDisabled.P2 — deferred (with reason)
install.go:171+mcpserverserver_test.go): the weak test lives in themcpserverpackage,which is out of scope here and owned by a parallel PR.
install.go'spermission block already derives from
mcpserver.ReadOnlyTools(), so it doesnot drift from the annotations; only the test is tautological. Left untouched
to avoid colliding with the mcpserver PR, per instructions.
Notes for maintainer
P0 (vs. the fallback of a
.bak+ doc correction), and it makes theformat-preserving promise in ADR 0003 and
runtime.gohonest — so I updatedthe
runtime.gopackage doc to describe the actual semantics rather thanweakening the ADR. Please sanity-check the inline-entry refusal behavior:
it is conservative (refuse rather than risk corruption) but means a user with a
hand-authored inline
mcp_servers.sting = {...}must edit manually.grokServer.Envremoval (see P1) is a deliberate deviation from the finding's"populate it" suggestion; rationale above. Happy to instead add
EnvtoEntryif you prefer that trade-off.