feat(mcp): make the stub roster shippable via a sparse override map - #8333
Conversation
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound layered design — roster vs. sparse deviations — but the seeder can still overwrite an explicit operator decision, the one thing the layer split promises not to do. Watch
[DESIGN-REVIEWED] 471b6ea |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've traced the single candidate against the actual code in Candidate 1 (seed.py:117 — seeding clobbers an operator's No additional groundable defect surfaced: the No findings. [OPUS-REVIEWED] 471b6ea Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ human override accepted@chenmingwei23 overrode this lane for |
UX Review (Fable 5) — ⏭️ skippedRevision |
|
/ai-review override first-principles 5c26f8d: Verified inverted. The three commits named as riders (b206a9f, 711544f, 5df44de) are ancestors of main and are NOT in this branch; 5df44de is main's current HEAD, ahead of this branch's base 18a88e3. This PR's own file list is 13 files, all MCP-override work: no chat/share sources, no temp-screenshots, no package.json, no test_review_driver.py. The requested subtraction is already the shipped state. |
Human judgment recorded@chenmingwei23 marked the first-principles AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
9cb7d90 to
24f4060
Compare
`mcp_gateway.stub_servers` is now the stub ROSTER and the new `mcp_gateway.stub_overrides` carries the operator's deviations from it, so the two layers move independently. A toggle used to persist the resulting set back into `stub_servers`, which made that key un-shippable: the first click took ownership, and every later addition by whoever assembled the install arrived into a file that already answered the question. A flat list can only be REPLACED -- unstubbing one server out of a shipped set means writing back the survivors, and such a list cannot distinguish "I do not want b-mcp" from "I have never heard of c-mcp". Recording the decision instead leaves every server the operator never spoke about following the roster. - `_resolve_stub_servers` splits into `_resolve_stub_roster` (the base layer, where the legacy `poolable_servers` migration and its `enabled` condition stay) plus `_resolve_stub_overrides`, resolved together so every reader picks it up. - The dashboard toggle records a decision and prunes an override that agrees with the roster: identical in effect now, and storing it would pin that server against the next roster change. Roster membership is untouched; its form is still normalized on write, because a duplicate makes `stub_count` overcount. - `_freeze_stub_servers` materializes the ROSTER, not the effective set -- folding overrides in would make a deviation indistinguishable from a shipped name. - `save()` no longer flattens the roster. It round-trips the config through `asdict`, and the dataclass field holds the EFFECTIVE set, so any unrelated save rewrote the file without the servers the operator had opted out of, turning a reversible deviation into a permanent deletion. The raw roster is carried on the section as a private field and `to_dict` emits that instead; the leading underscore keeps it out of the schema and baseline, and `to_dict` pops it. - The overlay-shadow guard covers `stub_overrides` too. The overlay wins the deep merge per-key, so a click on a shadowed key would have answered 200 while the gateway kept routing the old way -- the same silent never-takes-effect failure the guard exists to prevent, on the key the write moved to. - Seeding records an override and skips a name the roster already carries, so a local discovery no longer merges into the layer its owner keeps growing. - The freeze ratchet guards the new key and credits a caller that delegates its write to a section helper, so it cannot go blind on a refactor. - Baseline regenerated for the new key, and its encoder now drops private dataclass fields so it agrees with the schema walker, which already skips them.
24f4060 to
471b6ea
Compare
|
/ai-review override first-principles 471b6ea: Order-dependence, not YAGNI. The old toggle wrote the resulting set back into stub_servers, permanently destroying which names were decisions. Land a roster producer first and every install that has ever toggled has already flattened, so there is nothing left to preserve: the decisions layer must precede any producer. This review made that argument itself in its previous round. Separately, save() flattening the roster is a live data-loss bug today, independent of any producer. |
Human judgment recorded@chenmingwei23 marked the first-principles AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
What is the problem?
mcp_gateway.stub_serversis the per-server stub decision, and the MCP Management toggle persisted the resulting set back into it. That makes the key un-shippable.An install whose stub set is assembled by someone other than the person clicking - an edition that wants its known servers stubbed out of the box - has no layer of its own. The first toggle takes ownership of
stub_servers, and every later addition to that list arrives into a file that already answers the question, so it silently never takes effect.seed.pyhad the same shape: it merged its discoveries straight into the same key.The underlying reason is that a flat list can only be replaced. Unstubbing one server out of a shipped set means writing back the survivors, and a written list cannot distinguish "I do not want b-mcp" from "I have never heard of c-mcp", so it answers no to both.
Why this issue matters to the user
Two things a user should be able to do at once, which today conflict:
Today the second one silently costs them the first. The moment they deviate, their config shadows the whole set, and the failure is invisible: no error, no log, the row simply never appears. The seeding path makes it worse: a name it seeded can be dropped by a later change to the same key and, because seeding is once-per-server, never re-added.
How our fix solves it
stub_serversbecomes the roster and the newmcp_gateway.stub_overrides(a sparsename -> boolmap) carries only what the operator changed. Effective set = roster, minus explicitfalse, plus explicittrue. A server nobody spoke about follows the roster, so the roster can grow and reach them while their own opt-outs survive.Following the chain from the symptom to each writer:
_resolve_stub_roster(base layer; the legacypoolable_serversmigration and itsenabledcondition stay here, unchanged) and_resolve_stub_overrides, resolved together by_resolve_stub_servers. Every existing reader picks the change up without knowing about it, and the loader keeps assigning the effective set tocfg.stub_servers, so the dashboard rows andstub_countneed no change.stub_countovercount._freeze_stub_serversnow materializes the roster rather than the effective set. Freezing the effective set would fold the operator's deviations into the base layer, making them indistinguishable from a shipped name, which is the exact shadowing this change removes.seed.pyrecords an override too, and skips a name the roster already carries.What tests we did
test/test_mcp_stub_overrides.py(18 cases): roster followed under silence, both deviation directions, junk values dropped rather than coerced, the roster resolver proven to ignore overrides, and the load-bearing case: a roster addition reaching an operator who deviated on a different server, with their deviation surviving it.stub_overridesis now a guarded key and a caller that delegates to a section helper is credited with that helper's writes, so the ratchet is stricter than before.flake8,isort,mypyclean; the black gate passes on the 11-file scope.Any other suggestions on the work
stub_serversis unaffected by a roster shipped later, which is correct for a default but means a roster addition reaches new installs only.apps/manager.pysolved the identical problem with a second, deliberately small set (_DEFAULT_ON_BACKFILL) distinct from the fresh-install one; the same split would fit here and is left out of this change on purpose.seed.pyis a correct writer today, but nothing structurally stops a third one appearing outside the handler.pooling_blocked_by_envgap noted inrowStateis unchanged here: the rewriter also declines when it cannot resolve the command, and the row payload carries no signal for that, so such a row still readsshared.