Skip to content

feat(mcp): make the stub roster shippable via a sparse override map - #8333

Merged
iamwhatever merged 1 commit into
mainfrom
feat/mcp-stub-overrides
Sep 4, 2026
Merged

feat(mcp): make the stub roster shippable via a sparse override map#8333
iamwhatever merged 1 commit into
mainfrom
feat/mcp-stub-overrides

Conversation

@chenmingwei23

Copy link
Copy Markdown
Contributor

What is the problem?

mcp_gateway.stub_servers is 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.py had 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:

  • Receive a sensible stub set without curating 42 servers by hand.
  • Turn any single server off and keep that choice.

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_servers becomes the roster and the new mcp_gateway.stub_overrides (a sparse name -> bool map) carries only what the operator changed. Effective set = roster, minus explicit false, plus explicit true. 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:

  • The resolver splits into _resolve_stub_roster (base layer; the legacy poolable_servers migration and its enabled condition 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 to cfg.stub_servers, so the dashboard rows and stub_count need no change.
  • The dashboard toggle records a decision instead of a set, and prunes an override that agrees with the roster. The two are identical in effect today and differ tomorrow: stored, the entry would pin that server against the next roster change. The prune is also what keeps the map sparse under the "stub every server the evidence allows" batch, which would otherwise write an entry per server and shadow the roster wholesale. Roster membership is untouched; its form is still normalized on write, because the old rebuild-from-a-set incidentally deduplicated it and a surviving duplicate makes stub_count overcount.
  • _freeze_stub_servers now 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.py records an override too, and skips a name the roster already carries.

What tests we did

  • New 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.
  • Two new handler cases: the prune on toggling back, and the same roster-growth scenario end to end. The second asserts the roster is intact before the next release is simulated, so overwriting it cannot mask a handler that had already taken it over.
  • Updated existing suites to the new contract, asserting the effective set through the production resolver so the tests cannot drift from the runtime's answer.
  • Mutation-verified both new invariants: disabling the prune fails the prune test with its own message; making the handler own the roster again fails 8 tests.
  • The freeze ratchet caught this change itself, because moving the write into a helper made its detector blind. Per its own instruction the detector was updated, not the test: stub_overrides is 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.
  • 212 passed across the handler, resolver, migration, freeze-ratchet, seed, opt-in, module-boundary and black-gate suites. flake8, isort, mypy clean; the black gate passes on the 11-file scope.

Any other suggestions on the work

  • Existing installs need a separate decision. A config that already carries stub_servers is unaffected by a roster shipped later, which is correct for a default but means a roster addition reaches new installs only. apps/manager.py solved 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.
  • The seeding path is not covered by the freeze ratchet, which only scans the dashboard handler. seed.py is a correct writer today, but nothing structurally stops a third one appearing outside the handler.
  • The pooling_blocked_by_env gap noted in rowState is 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 reads shared.

@chenmingwei23
chenmingwei23 requested a review from a team as a code owner September 4, 2026 00:13
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 471b6eaccc759b1257b7b5bf60cffb509770bcdc and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 471b6ea

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 471b6eaccc759b1257b7b5bf60cffb509770bcdc: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 471b6eaccc759b1257b7b5bf60cffb509770bcdc — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

  • apply_seed filters additions only against the roster ({name: True for name in plan.add_stub if name not in roster}), so an existing stub_overrides: {name: false} for an unrostered server is overwritten to true. Reachable when a config travels without its verdict cache (fresh machine, wiped cache): seeding re-runs and flips a decision the file explicitly recorded — contradicting the shareability spec's "a later start … must not undo their choice." Skipping names already keyed in the override map closes it in one clause.
  • Related asymmetry worth a conscious yes/no: the UI prunes an "off" toggle for an unrostered server to nothing ((name in roster) == stub), so the operator has no way to record "never stub this" ahead of seeding or a roster addition — silence and refusal are the same byte. Documented as intended for roster-following; it also means the fix above is only expressible by hand-editing the file.

[DESIGN-REVIEWED] 471b6ea

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 471b6eaccc759b1257b7b5bf60cffb509770bcdc — this comment is updated in place on each push.

Review details

I've traced the single candidate against the actual code in seed.py, sections.py, and the dashboard handler.

Candidate 1 (seed.py:117 — seeding clobbers an operator's false override): Falsified. The clobber only occurs for a server the roster does NOT carry, and in that case a false override is inert by the module's own contract: _resolve_stub_servers treats a false on an unrostered name as a no-op (test_an_override_naming_an_unrostered_server_false_is_inert), and _record_stub_decisions explicitly prunes any override that "agrees with its base" as "indistinguishable from silence in effect." For a server the roster DOES carry, additions = {name: True for name in plan.add_stub if name not in roster} filters the name out, so merged == current and the operator's false is preserved. The marker guard (was_applied → skip) protects every post-seed opt-out. The only reachable case is a hand-edited inert false on an unrostered, never-evaluated server before first seed — which the candidate itself rates low confidence and cannot establish occurs in practice, and which the design's silence-equals-agreement model makes intended rather than wrong. (a) does not survive as more than "could." Dropped below threshold.

No additional groundable defect surfaced: the to_dict roster round-trip and the freeze/record split are exercised directly by the new tests.

No findings.

[OPUS-REVIEWED] 471b6ea

Verdict parsed from the review's SHA-scoped output markers for commit 471b6eaccc759b1257b7b5bf60cffb509770bcdc.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 471b6eaccc759b1257b7b5bf60cffb509770bcdc: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ human override accepted

@chenmingwei23 overrode this lane for 471b6eaccc759b1257b7b5bf60cffb509770bcdc via /ai-review override (this commit only).

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ⏭️ skipped

Revision 471b6eaccc759b1257b7b5bf60cffb509770bcdc touches no user-facing surface (no changes under website/ or committed screenshots), so the UX review was skipped. Advisory — does not block merge.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

/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.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Human judgment recorded

@chenmingwei23 marked the first-principles AI finding as false positive, not applicable, or explicitly accepted for 5c26f8debbae3de53de8a8f0e9ad9e751669c5cd.

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.

This decision applies only to this commit. A new push requires a new judgment.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Sep 4, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/mcp-stub-overrides branch from 9cb7d90 to 24f4060 Compare September 4, 2026 02:26
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 4, 2026
`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.
@chenmingwei23
chenmingwei23 force-pushed the feat/mcp-stub-overrides branch from 24f4060 to 471b6ea Compare September 4, 2026 04:53
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 4, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

/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.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Human judgment recorded

@chenmingwei23 marked the first-principles AI finding as false positive, not applicable, or explicitly accepted for 471b6eaccc759b1257b7b5bf60cffb509770bcdc.

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.

This decision applies only to this commit. A new push requires a new judgment.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 4, 2026
@iamwhatever
iamwhatever merged commit 7528abf into main Sep 4, 2026
64 of 65 checks passed
@iamwhatever
iamwhatever deleted the feat/mcp-stub-overrides branch September 4, 2026 16:09
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 4, 2026
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