fix(#59): wire read()'s onSkip into every caller — incl. a v0.34.0 env-loss regression in mcpm update - #199
Merged
Merged
Conversation
…date (#59) Since #23 (v0.34.0) BaseAdapter.read() drops an entry that fails shape validation, so readExistingEnv()'s `servers[name]?.env` returned undefined for a malformed entry and the `force: true` re-write discarded its env block — a user's API keys, lost silently, for an entry malformed in some other field. readExistingEnv now distinguishes "no env" from "read() dropped it" and the caller refuses the destructive write, surfacing it through the existing clientErrors channel (human output + --json). The pre-existing env-preservation test could not see this: it mocks read() to return the entry, which is the one thing the real read() stopped doing.
Byte-identical duplicates (esbuild warned on every run). The second silently won, so changing only the first would have had no effect.
#59) collectClientStates discarded read()'s onSkip signal, so an entry dropped for failing shape validation was indistinguishable from one that was never there. sync --check therefore told the user a client was MISSING a server it demonstrably has (sending them to re-add it), and when only one client held the server it vanished from the model entirely — a clean exit 0 over a config mcpm could not read. ClientState/ServerDrift gain `malformed`; a malformed holder is excluded from both `absent` and the shape comparison, and counts as drifted (exit 2 already means "not clean, read the output" — no new exit code). Rendered as ? with its own legend entry and detail line. doctor feeds its existing skippedEntries in so the cross-client view stops contradicting the DoctorIssue it already raises. `malformed` on ClientState is optional, so callers predating this compile unchanged.
Both assertions were too weak and survived their mutants: toContain("?") was
satisfied by the detail line under the table rather than the matrix cell, and
doctor had nothing pinning the malformed-entry wiring at all.
The last five call sites left on the stderr-only default, each of which made a claim the dropped entry falsified: - diff reported a declared-but-unreadable server as "missing" — sending the user to `mcpm up` to re-install over an entry they only need to fix. New "unreadable" DiffStatus; an UNDECLARED unreadable entry was invisible to both loops and now surfaces too. - export silently omitted it from a stack file the user keeps as their declared state. Named on stderr (not `output`: with no --output the YAML itself goes to stdout). - import silently dropped it from the pick-list, and could then print "No existing MCP servers found" when unreadable entries were the only ones present. Reported before that line. - up --strict left it behind while reporting a clean reconciliation. Still NOT deleted — the fail-safe direction #23 chose is kept — but reported as skipped. Silence was the bug, not the refusal to delete. diff --json gains a status value; it is UNSTABLE per CONTRACTS. sync --json is the frozen one and gains only an additive field.
…nd by dogfooding - export/import warned that a name was omitted when ANOTHER client had supplied a well-formed copy of it — the export was complete and the warning said otherwise. Only names no client could supply are reported now. - sync's summary read "1 drifted (0 missing, 0 shape conflicts)" — a drift count with no stated cause. Unreadable entries are counted in it. - doctor's cross-client line rendered "in ; missing in cursor" when the only holder's entry was unreadable: an empty present list, and no mention of the client that actually holds the server. New "unreadable" DoctorDriftEntry kind; the absent branch names unreadable holders too. Also strengthened the first doctor test with a positive control — it asserted an absent-list exclusion that would have passed vacuously had the entry dropped out of the drift list entirely.
Two independent Opus reviews (correctness/security, test-coverage) with opposed mandates. Four of the findings were regressions the fixes themselves introduced, and two reproduced verbatim the bugs the CHANGELOG claimed to have closed. Correctness: - diff went COMPLETELY SILENT when a name was malformed in one client and valid in another — neither loop fired, and because diff passes its own onSkip the default stderr warning was gone too. `1 in sync` over a config it could not read, worse than the behaviour it replaced. - update's refusal was the wrong fix. It printed `✓ Updated`, advanced the store and wrote zero configs, and its own remediation was unreachable (the warning fires once, then "All servers are up to date"). Worse, overwriting a mis-shaped entry is the user's SELF-REPAIR path, so refusing turned a self-healing case into a stuck one. onSkip now carries the raw entry and update recovers env from it with a narrow parse: repairs AND preserves. - sync still printed `in ; missing in cursor` — fixed in doctor, missed in the command the same CHANGELOG bullet holds up as the CI gate — and double-counted the server under "missing in >=1 client". - export's `name in servers` walked the prototype chain, so entries named toString/constructor/valueOf vanished from the warning and the file. - Names reached the terminal unsanitized in three new render sites. - Both guard/cli.ts rationales were INVERTED: keeping the default is what duplicated the line; a no-op is what removes it. - list was neither wired nor listed as a deliberate exception. - import's "No existing MCP servers found" contradicted the warning above it. - doctor's conflict branch dropped malformed, asymmetric with absent. Test coverage — the headline finding is mine, in the class I sent the reviewer hunting for. The central drift test built a state read() can never produce (a name in both `servers` and `malformed`; base.ts is an if/else), so its assertion was true by construction and deleting the fix left the file green. A second test passed against unmodified origin/main. Also unpinned: the drifted predicate's malformed clause, update's name filter, up --strict's recordResult channel, import's good-copy filter, diff's guard, both doctor render branches, and every sanitize call. 20 mutations, all caught. Dogfooded end to end per finding.
…ilent CI failure
The third review re-derived from the diff rather than checking the fix list,
which is how this project's history says its worst bugs get found. Both HIGH
findings reproduced.
H2 — the env-recovery fix had a hole in exactly the population it targets.
`env` is frequently the field that makes an entry invalid (a numeric port is
the archetypal hand-edit), and parsing the whole record then rejected EVERY
key: measured, `{API_KEY:"s3cret", PORT:8080}` came back as no env at all,
still under `✓ Updated`. Recovery is now PER KEY, and a key that cannot be
carried is named rather than dropped in silence. Same site: replacing the
default onSkip had silenced the warning for unrelated malformed neighbours;
re-stated.
H1 — `sync --check` exited 2 while printing only "nothing to compare across
clients". renderDashboard returns early for 0/1 clients and for no servers,
but `drifted` comes from the model, so on the common single-client desktop
shape a CI gate failed with the entry named on neither stream. Unreadable
entries and clients are now reported before every early return.
M3 — an entirely unparseable config passed --check silently while one bad
entry inside it failed. Both fail now.
M1 — up --strict's two render sites took the name raw, while the CHANGELOG
already claimed sanitization at "every new render site". Fixed both.
M2 — list --json flipped from a bare array to an object only when something
was skipped, breaking JSON.parse(out).map() exactly when things are wrong.
Shape is unconditional again; the notice goes to stderr, which the CLI has
and the MCP surface does not (that is why handlers.ts differs).
L1/L3 — CONTRACTS.md records the sync --json and exit-code change instead of
still calling it frozen-and-additive; sync's "N missing in >=1 client"
sub-count comes from the model again rather than from a filtered section.
Not acted on: L6 was stale (the reviewer read a pre-fix fixture; the shipped
one already uses the reachable shape) and L5 is a pre-existing __proto__ hazard
in valid-entry handling, filed rather than ridden in.
…s 'could not update' it Routing the #59 recovery notes through clientErrors made the success line say "warning: could not update claude-desktop" about a client it had just written.
0 HIGH; three MEDIUM, two of them the class the previous two commits were written to remove. M1 — `update` said a server was "not updated" one line before "✓ Updated" it. The neighbour warning fired from inside the per-server read, so it named a server this run was about to update, and repeated once per updated server. Collected across the run and reported once, excluding names this run updated. M2 — the CHANGELOG described two implementations the diff REJECTED: a `z.record(z.string(), z.string())` parse (there is none; the code argues against it two lines above) and a `list --json` `skipped` field (routed to stderr on purpose). Both checkable against files in the same PR. Every remaining claim re-verified against the code by grep. M3 — `sync` printed "No client configs found. Install a server first" immediately after naming the configs it found: false, and the wrong remediation for broken JSON. The same contradiction this PR already fixed in import.ts and list.ts, left unfixed in the third place. Also: read()'s `raw` argument — the entire mechanism the env recovery depends on — was pinned by NO test; neutering it left all 2868 green while update silently wiped env blocks again. Pinned in base-read-validation. An env key named `__proto__` with a string value was dropped by the object-literal setter, contradicting the block's own promise to name what it cannot carry (Object.create(null) now; same class as v0.36.0's pin-hash fix). A non-object `env` returned without a note. Unused zod import removed. sync's parenthetical no longer adds a client count into a server count. .claude/settings.local.json was committed by accident in 77fb695 — a per-machine permissions file, untracked on main. Removed and gitignored.
/srv-b.*not updated/ required the name BEFORE the phrase; the report puts the phrase first, so it passed with the bug live (mutation W2 survived). Asserts on the exact rendering now, with a positive control.
…nowhere MEDIUM: the custom onSkip replaces read()'s stderr default for EVERY entry in the config, and the neighbour report was gated on !isJson — so under --json there was neither a field nor a stderr fallback, and a malformed entry became LESS visible than on main. That is this PR's own thesis inverted. list.ts had already solved the identical problem the right way; update --json was the one new call site that did not follow it. Also: `neighbours` was keyed by name alone, so the same malformed name in two clients reported one of them and which one depended on iteration order — now keyed by client+name. Stale JSDoc on SyncResult.drift (it is true for more than absence/conflict now). CONTRACTS' exit-code table — the row the doc tells scripts to depend on — did not mention that 2 now covers "could not verify"; the --json section 20 lines below did. Two dead assertions removed: one matched "not in mcpm.yaml", which lives only in results[].message and is never rendered on the strict-removal path, so it could not fail whatever the code did; the other asserted a prototype-pollution needle nothing in the test writes, replaced with a real prototype check.
…name]) A NUL separator worked but made every tooling round-trip over this file awkward. Same uniqueness guarantee, no control character.
…name Round 5 changed the neighbours map KEY to (client, name) because the same malformed name in two clients is two facts. The FILTER was left name-scoped, so a malformed copy in a client the server is NOT installed in — never written, never reported — was suppressed by the name's own success elsewhere. Suppression is now keyed by the (client, name) pairs actually re-written. The fix passed all 42 existing tests, so it was unpinned; added the exact cross-client case.
sync was its only production caller and moved to collectClientStatesWithErrors; folded its tests onto the surviving function. Also two indentation regressions introduced earlier in the branch.
Round 7 converged with no defect, but three lines survived the whole suite: `writtenPairs.add` sitting AFTER the await (moving it before lets a failed write suppress the report for an entry still malformed on disk), and the sanitize calls on update's neighbour notice, its dropped-env-key note, list's warning and export's warning — all four render values a config file controls, while every other new render site already had an escape test. Also corrects the CHANGELOG's sanitized-site list, which under-enumerated for the third time.
The duplicate copy actively hid a gap: a sanitization mutation applied to the --json path survived, because the only test covering that sentence exercised the text path. One builder, two sinks.
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.
Closes TODOS #59 —
BaseAdapter.read()'sonSkipseam (#23, v0.34.0) was not wired into every caller.Reading the call sites first turned up something the backlog had not: one of them is a data-loss regression, not a reporting gap. #59 was filed as "fail-safe as-is — a coverage gap, not a live bug."
1.
mcpm updatedestroyed a malformed entry'senvblockreadExistingEnvreads throughread(), which since #23 drops an entry failing shape validation. So for a user whose entry is malformed in one field (args: "-y pkg"instead of an array) but whoseenvholds real API keys, it returnedundefined— and theforce: truere-write discarded them.Verified against the pre-fix binary in an isolated
$HOME:...while the entry went from
{command, args, env:{MY_API_KEY:"super-secret-value"}}to{command, args}. It reports success while deleting the secret. Shipped in v0.34.0, live through v0.36.0.The existing env-preservation test (
"carries the user's existing env values into the new entry", added as "Fix #1 — a regression would silently wipe them") could not see it: it mocksread()to return the entry, which is the one thing the realread()stopped doing.readExistingEnvnow distinguishes "has no env" from "read() dropped it"; the caller refuses the destructive write and reports through the existingclientErrorschannel.2. A malformed entry was reported as a missing server
Five sites, each making a claim the dropped entry falsified:
sync --checkdiffmcpm upto re-install over an entry they only need to fixexportimportup --strictup --strictstill does not delete it — the fail-safe direction #23 chose is kept. Silence was the bug, not the refusal to delete.guard/cli.ts's two sites deliberately stay on the default.planForClientalready routes the entry intoskippedwith a reason andprintClientReportprints name + reason on both enable and disable, so wiring them would print the same name twice in one command. Recorded in the code rather than left looking unfinished.Three of the fixes introduced false statements of their own
All found by dogfooding the built binary, none by the suite:
export/importwarned a name was omitted when another client had supplied a good copy — the export was complete and the warning said otherwise.syncsummarised1 drifted (0 missing in ≥1 client, 0 shape conflicts)— a drift count with no stated cause.doctorrendered⚠ broken-srv — in ; missing in cursor: empty present list, and it never named the client actually holding the server.Verification
tsc --noEmitclean.toContain("?")was satisfied by the detail line under the table rather than the matrix cell, and doctor's wiring had nothing pinning it at all. One doctor assertion also got a positive control — it checked an absent-list exclusion that would have passed vacuously had the entry dropped out of the drift list entirely.$HOMEacrosssync,diff,export,import,doctor,list,up --strict, andupdate(including the pre-fix counterfactual above), for both the single-client and cross-client cases.Contract notes
sync --jsonis the frozen shape and gains only an additivemalformedfield.diff --jsongains anunreadablestatus value anddoctor --jsonanunreadabledrift kind; both are UNSTABLE perdocs/CONTRACTS.md.Also removes six byte-identical duplicate
readkeys in adapter test helpers (esbuild warned on every run; the second silently won, so changing only the first would have had no effect).