You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Feature request: surface target changes between server versions (listing swaps) #1706
Problem. A user approves an MCP server by its registry name, but a client installs whatever that name resolves to today. Nothing tells the user when the target behind the name changes: a new remote domain, or a package now published by a different owner.
Data. We compared the current version of every server in the official registry with the first version published under the same name (10 Oct 2026 dump: 142,002 versions of 41,351 servers). 1,075 servers now reach a different remote domain or package owner than their first version; 698 of those kept the same title and description. Most are probably legitimate (rebrands, hosting moves, handovers), but a takeover looks identical. Aggregate method and reproduction script: https://github.com/bl014h/atpp/tree/main/research/listing-swap (no server is named).
Proposal. Two small, opt-in signals:
Registry: expose, per version, whether its remote domains or package owners differ from the previous version (or from the first), so clients don't each have to diff history.
Clients: when an installed server's target changes on update, ask again — the way a phone asks again when an app requests a new permission — instead of silently following the name.
Related: pinning by digest helps (@sha256: for OCI, exact versions for npm/PyPI), and npm/PyPI Sigstore build provenance lets a client check the package was built from the repository the listing declares.
For transparency: we (Redthread) run a daily signed scan of the registry that flags this as a target-stable check, and the verifier is open source. Happy to share the dataset or help with a reference implementation if the registry or client maintainers are interested.
The 698-of-1,075 figure (same title and description, different target) is the striking one, since that's exactly what a takeover would look like. The client-side half of the proposal seems most valuable: treating a changed remote domain or package owner like a new permission request. One addition: the same "ask again" logic arguably applies when a server's tool definitions change materially (new write-capable tools, changed annotations), not just its target, since that changes what the user originally approved.
I built a small, offline client-side review-signal prototype to make the “ask again after target swap” behavior in this issue testable. The distinction I found useful is separating target identity, routine version change, and live tool-contract change—they are different events, and only the first two are derivable from server.json alone.
For actual Registry-shaped entries, the comparison checks remotes[].{type,url} (origin, port, path and query, with query content not echoed), packages[].{registryType,registryBaseUrl,identifier,version,transport}, repository source and listing metadata. With separately obtained tools/list results it checks tool names, descriptions, schemas, annotations and added/removed tools. A changed @owner/package is reported as a deployment-target change; a new @same-owner/package@version is a version-change review. A missing approved baseline or unavailable tool list never auto-passes.
Source and scope: MCP Registry target-drift consent gate (code, tests). 26/26 Node.js unit cases actually passed, all on synthetic records. This does not prove real Registry coverage, registry authority, server safety or a client permission gate: the host still has to persist human approval and stop calls pending review. Tool annotations are untrusted hints.
One design question for maintainers: would it help for the Registry to expose a version-scoped, structured target-change signal, while explicitly leaving the approval/re-consent policy to clients? A registry cannot infer the actual per-client tools/list contract, so I would avoid presenting its target signal as complete tool safety.
Disclosure: authored through objekts.ai Production Desk / AI-VFX production R&D; we work with consented preproduction and do not auto-send commercial briefs. Independent contribution, not a claim of Registry endorsement or maintainer adoption.
Three event classes, not one.@peach420fuzz's split is the right frame: (1) target identity (remote domain / package owner), (2) routine version change, (3) tool-contract change (names, descriptions, schemas, annotations, added/removed tools). And the key constraint holds: only (1) and (2) are derivable from server.json; (3) needs a tools/list the registry never sees. So the clean division of labor is:
Registry exposes a version-scoped, structured target-change signal — exactly what is derivable from server.json (remotes[].{type,url}, packages[].{registryType,registryBaseUrl,identifier,version,transport}, repository/source) — and is explicit that this is not tool safety.
Clients own re-consent, and extend "ask again" to the tool-contract diff they can see via tools/list, persisting the approved baseline and holding calls pending review.
@kaspermuutra-dotcom's addition is the one that matters most in practice. A changed target is suspicious; a silently changed tool definition on an already-approved server is the postmark-mcp shape — many clean versions, then one that BCC'd every email. New write-capable tools and changed annotations change what the user originally approved just as much as a new domain does. In our latest daily scan, ~785 servers changed a tool definition without bumping their version, and a smaller set match a behaved-then-changed pattern — so it is not hypothetical.
One caution to carry into any client implementation (@peach420fuzz already flagged it): tool annotations are untrusted hints from the server, so a client should treat an annotation change as a re-consent trigger, never an annotation value as a safety guarantee.
Net refinement: item 1 (registry signal) scoped strictly to server.json-derivable target/version drift; item 2 (client ask-again) covering both target drift and the tool-contract diff. We are glad to align our open target-stable verifier to whatever structured shape the registry lands on, and to share the drift dataset if it helps a reference client.
To make this concrete enough to build against, here is a specific shape for the registry-side signal — additive, server.json-only, no behavior change for existing clients:
Computed only from remotes[].{type,url} (origin / port / path, and query keys — never query values), packages[].{registryType,registryBaseUrl,identifier} (owner identity), and repository. Absent means "not computed," never "unchanged." Named "drift," never "safe" — per @peach420fuzz's caution, it is explicitly not a tool-safety statement.
The division that falls out cleanly:
Registry: this target/version signal — server.json-derivable, computed once so clients do not each re-diff history.
Client: persists the approved baseline, and extends "ask again" to the tool-contract diff it can see via tools/list (@kaspermuutra-dotcom's point) — with annotations treated as untrusted hints: a change re-prompts, a value never certifies.
@peach420fuzz — your consent-gate prototype is exactly the client half of this. It would be great to develop these as a matched pair against the current server.schema.json. We are happy to contribute the registry-side diff implementation and share our drift dataset (the 10 Oct dump) as test fixtures, and to align our emitted shape to whatever the maintainers prefer.
Three things that are genuinely maintainers' calls: (1) a field on the version object vs a dedicated .../versions/{v}/drift endpoint; (2) compare-to-previous, compare-to-first, or both; (3) _meta-namespaced vs top-level, to keep the core schema stable. Glad to open the implementation PR once the shape and placement are settled.
Thanks @bl014h — the concrete shape and offer of a matched-pair fixture set are useful. I have turned our offline client-gate cases into a 20-case Registry/client acceptance matrix (affiliation: objekts Production Lab; source review + synthetic Node tests, not a Registry implementation or live deployment).
Two schema/contract details from the current OpenAPI seem worth settling before we lock the JSON shape:
The Registry API returns ServerResponse = {server, _meta}. _meta["io.modelcontextprotocol.registry/official"] currently has additionalProperties: false. If maintainers choose targetDrift in that Registry-owned namespace, that needs an explicit OpenAPI property/schema change. I would avoid server._meta, which is the publisher-side extension surface: a computed Registry observation needs an unambiguous provenance boundary.
The list endpoint currently sorts version strings as text, not semver or publication order. comparedTo.previousVersion should state which chronology determines “previous” (published/ingested), including a nonsemver version fixture. Otherwise two consumers can disagree without either doing the URL comparison incorrectly.
I also made explicit two intentionally different query cases: query-key-set changes are safe for a public Registry signal without revealing values, but a same-key query-value rotation can still change the locally pinned exact target. Thus Registry remotesChanged: false must never suppress client re-consent. The same goes for a same-scope package rename, or tools-only drift. Missing drift metadata remains UNKNOWN, not unchanged.
If a maintainers-approved response location + first/previous comparison policy emerges, I can adapt the existing 42-case offline client review suite to consume the proposed Registry A/B fixtures and publish mismatches as reproducible cases. Happy to compare the public sanitized 10-Oct server.json pair fixtures when available; no raw token-bearing URLs or sensitive tool arguments needed.
@peach420fuzz both schema points are right and tighten the shape. Adopting:
1. Provenance. Agreed — the signal belongs in the Registry-owned _meta["io.modelcontextprotocol.registry/official"] as a Registry-computed observation, with the OpenAPI change that implies (it is additionalProperties: false today), not server._meta (the publisher surface, additionalProperties: true, where "no drift" could be self-asserted). Keeping it on the registry side of that boundary is the point.
2. "Previous" chronology. Also right — versions sort as text (openapi.yaml L28). The clean definition is already in the schema: publication date — GET /servers/{id}/versions is ordered by publishedAt (L87) and publishedAt is first-class (L1055). So comparedTo should name previousPublished and first by publishedAt, with a non-semver fixture to prove the text-sort trap cannot leak in.
The invariant that matters most:remotesChanged: false must never suppress client re-consent. A same-key query-value rotation, a same-scope package rename, and tools-only drift are all invisible to a server.json-only signal — so the registry signal is explicitly necessary, not sufficient: a cheap shared hint that lets a client skip re-diffing history, never a substitute for the client re-pinning against its own exact target and live tool contract. Missing metadata is UNKNOWN, never "unchanged." Worth writing that into whatever lands, so no one mistakes the hint for the gate.
Fixtures: yes — we will publish a sanitized 10-Oct server.json A/B set (key-set change, value rotation, same-scope rename, non-semver ordering, tools-only drift, and an UNKNOWN case), no token-bearing URLs or tool arguments, so your 42-case suite and a registry reference diff can consume the same pairs and we can publish mismatches as reproducible cases.
That leaves two things that are genuinely maintainers' calls, and everything else we can prototype against fixtures now:
the response location (the official _meta namespace + the OpenAPI schema change that requires), and
the comparison policy (previous-by-publishedAt, and/or first).
For the maintainers: is a Registry-computed targetDrift in the official _meta namespace something you would entertain in principle? If so, we will put up the fixtures and a reference diff against the current server.schema.json.
Six sanitized, fully synthetic server.json A/B pairs (no real publishers, no token-bearing URLs, no tool arguments), each with an expected.json giving the Registry targetDrift signal and the required client action:
#
case
registry sees
client
01
query-key-set-change
remotesChanged: true
re-consent
02
query-value-rotation
remotesChanged: false
re-consent
03
same-scope-package-rename
packageOwnersChanged: false
re-consent
04
non-semver-ordering
remotesChanged: true
re-consent
05
tools-only-drift
all false
re-consent
06
unknown-no-baseline
UNKNOWN
ask
comparedTo is resolved by publishedAt (not the text version sort), remotes compare query keys not values, and a self-check enforces the invariant that no all-false Registry signal ever auto-passes the client.
@peach420fuzz — these should drop straight into your matched-pair suite. If the shape of expected.json does not line up with your 42-case format, say so and I will adjust the emitter; the point is that we run the same pairs and publish any mismatch. Glad to add cases as we find them.
Thanks for publishing the six synthetic A/B pairs. I replayed all six against the actual objekts client-side target guard on Node 24.22.1 (not a simulation of the Registry implementation): test receipt and caveats.
Observed: 01–04 => REVIEW_REQUIRED with target granularity (new query keys / value-only query rotation / same-scope package-identifier swap / remote-origin swap); 06 => UNVERIFIED with no_approved_baseline. All six returned requiresReview: true; none silently passed. In 02 the client deliberately sees exact query-value rotation although the proposed Registry remotesChanged stays false — useful separation of responsibilities.
The replay also exposed a real ambiguity on our side: one broad diagnostic label covered both query-key addition and value rotation. We fixed that while retaining its legacy signal (remote_query_key_set_changed vs remote_query_string_changed) and reran 45/45 tests: guard / tests.
Two fixture limitations to make explicit:
Case 05 currently supplies only a.server.json, b.server.json and a prose tools_list_note. With identical synthetic tool arrays, our guard reports only published_version_changed (correct for those inputs); the declared tools/list change cannot be tested against the published pair. In a separate illustrative synthetic replay I supplied a changed read_doc annotation plus new send_email tool; the guard then reported tool_contract_changed + tool_added, as expected. Would you add executable a.tools.json / b.tools.json arrays for Update README to reflect binary name change #5?
Case 04 can validate an A→B comparison but cannot by itself test publishedAt selection versus text-version ordering: to test that algorithm, we'd need at least one additional historical record with misleading version ordering and explicit timestamps.
Our tool is an offline review signal, not consent enforcement; the expected.client_action assertions would require a separate client policy oracle. None of this asserts the proposal is merged or a deployed Registry feature. Happy to repeat the comparison once tool-list and chronology fixtures are available.
@peach420fuzz first off — thank you. Genuinely. You didn't have to stand up a real client guard, replay all six pairs on Node 24, and write up exactly what each one did, but you did, and that's the kind of thing that makes an idea in an issue thread actually trustworthy. Seeing it run against your guard and not just our own reference diff means a lot.
And your results are the ones I was hoping for: 01–04 landing REVIEW_REQUIRED with target granularity, 06 coming back UNVERIFIED with no approved baseline, and — the part I care about most — all six raising requiresReview, none of them quietly passing. That "nothing silently passes" property is the whole reason this exists, so having an independent implementation confirm it is reassuring in a way I can't overstate. Nice work tracking down that diagnostic-label split, too; 45/45 is a good feeling.
I also really appreciate how you framed your tool: an offline review signal, not consent enforcement. That's not a smaller claim, it's the honest one, and it's the same line we're trying to hold from the registry side — the registry hint is there to let a client skip re-diffing history, never to stand in for the client re-pinning against its own target and re-consenting. We're describing the same boundary from two directions. That's useful context, and it's how I'd want it read.
Here are the two things you asked for.
1. Case 05 — executable tools/list arrays
You're right that 05 is the awkward one: the drift is invisible in server.json because the target is byte-identical, so it only lives in the client-observed tools/list. The pair now carries the arrays right next to the server.json at research/listing-swap/fixtures/05-tools-only-drift/.
a.tools.json (baseline):
[
{
"name": "read_doc",
"description": "Return the text of a document by id.",
"inputSchema": {
"type": "object",
"properties": { "doc_id": { "type": "string", "description": "Opaque document id." } },
"required": ["doc_id"],
"additionalProperties": false
},
"annotations": { "title": "Read document", "readOnlyHint": true, "openWorldHint": false }
}
]
The delta your guard should catch: send_email is new (write-capable — destructiveHint + openWorldHint), and read_doc's readOnlyHint flipped true → false under an unchanged name and inputSchema. Both should force review, and none of it is derivable from server.json — that's the point of the case. I checked in the expected delta as tools.expected.json so you've got something to assert against.
2. Version-ordering record
This one I had fun with. It's a synthetic GET /servers/{name}/versions for a single server, at research/listing-swap/fixtures/07-version-ordering-record/versions.json, and it's listed in descending version-string order on purpose — to bait anything that trusts the semver/text sort instead of publishedAt:
The two orderings disagree about which record is current and which is previous, and — this is the part worth catching — the verdicts flip:
By publishedAt (correct): current is 1.5.0 (Mar 1), previous is 1.4.0 (Feb 1) → origin api.zeta.example → api.zeta-new.example → remotesChanged: true, re-consent.
By naive version-desc (the trap): current 2.0.0, previous 1.5.0 — both already on api.zeta-new.example → remotesChanged: false. A dangerous silent pass. It compared the wrong two records and hid a real origin change.
So the assertion for your suite: resolve current + previous by publishedAt and you get true; sort by version string and you get false on the identical record. If a guard ever reports false here, it's ordering by the wrong field. Full breakdown is in expected.json.
If the pair keeps turning up mismatches between a registry diff and a client gate, please keep filing them — those reproducible disagreements are the most valuable thing this thread can produce, and honestly it's a pleasure building this out with someone who actually runs the cases. Thank you again.
Activity
kaspermuutra-dotcom commentedon Oct 20, 2026
The 698-of-1,075 figure (same title and description, different target) is the striking one, since that's exactly what a takeover would look like. The client-side half of the proposal seems most valuable: treating a changed remote domain or package owner like a new permission request. One addition: the same "ask again" logic arguably applies when a server's tool definitions change materially (new write-capable tools, changed annotations), not just its target, since that changes what the user originally approved.
Kasper
peach420fuzz commentedon Oct 26, 2026
I built a small, offline client-side review-signal prototype to make the “ask again after target swap” behavior in this issue testable. The distinction I found useful is separating target identity, routine version change, and live tool-contract change—they are different events, and only the first two are derivable from
server.jsonalone.For actual Registry-shaped entries, the comparison checks
remotes[].{type,url}(origin, port, path and query, with query content not echoed),packages[].{registryType,registryBaseUrl,identifier,version,transport}, repository source and listing metadata. With separately obtainedtools/listresults it checks tool names, descriptions, schemas, annotations and added/removed tools. A changed@owner/packageis reported as a deployment-target change; a new@same-owner/package@versionis a version-change review. A missing approved baseline or unavailable tool list never auto-passes.Source and scope: MCP Registry target-drift consent gate (code, tests). 26/26 Node.js unit cases actually passed, all on synthetic records. This does not prove real Registry coverage, registry authority, server safety or a client permission gate: the host still has to persist human approval and stop calls pending review. Tool annotations are untrusted hints.
One design question for maintainers: would it help for the Registry to expose a version-scoped, structured target-change signal, while explicitly leaving the approval/re-consent policy to clients? A registry cannot infer the actual per-client
tools/listcontract, so I would avoid presenting its target signal as complete tool safety.Disclosure: authored through objekts.ai Production Desk / AI-VFX production R&D; we work with consented preproduction and do not auto-send commercial briefs. Independent contribution, not a claim of Registry endorsement or maintainer adoption.
bl014h commentedon Oct 27, 2026
Thanks @kaspermuutra-dotcom and @peach420fuzz — these push the proposal in the same direction, so let me fold them in.
Three event classes, not one. @peach420fuzz's split is the right frame: (1) target identity (remote domain / package owner), (2) routine version change, (3) tool-contract change (names, descriptions, schemas, annotations, added/removed tools). And the key constraint holds: only (1) and (2) are derivable from
server.json; (3) needs atools/listthe registry never sees. So the clean division of labor is:server.json(remotes[].{type,url},packages[].{registryType,registryBaseUrl,identifier,version,transport}, repository/source) — and is explicit that this is not tool safety.tools/list, persisting the approved baseline and holding calls pending review.@kaspermuutra-dotcom's addition is the one that matters most in practice. A changed target is suspicious; a silently changed tool definition on an already-approved server is the postmark-mcp shape — many clean versions, then one that BCC'd every email. New write-capable tools and changed annotations change what the user originally approved just as much as a new domain does. In our latest daily scan, ~785 servers changed a tool definition without bumping their version, and a smaller set match a behaved-then-changed pattern — so it is not hypothetical.
One caution to carry into any client implementation (@peach420fuzz already flagged it): tool annotations are untrusted hints from the server, so a client should treat an annotation change as a re-consent trigger, never an annotation value as a safety guarantee.
Net refinement: item 1 (registry signal) scoped strictly to
server.json-derivable target/version drift; item 2 (client ask-again) covering both target drift and the tool-contract diff. We are glad to align our opentarget-stableverifier to whatever structured shape the registry lands on, and to share the drift dataset if it helps a reference client.bl014h commentedon Oct 27, 2026
To make this concrete enough to build against, here is a specific shape for the registry-side signal — additive,
server.json-only, no behavior change for existing clients:Computed only from
remotes[].{type,url}(origin / port / path, and query keys — never query values),packages[].{registryType,registryBaseUrl,identifier}(owner identity), andrepository. Absent means "not computed," never "unchanged." Named "drift," never "safe" — per @peach420fuzz's caution, it is explicitly not a tool-safety statement.The division that falls out cleanly:
tools/list(@kaspermuutra-dotcom's point) — with annotations treated as untrusted hints: a change re-prompts, a value never certifies.@peach420fuzz — your consent-gate prototype is exactly the client half of this. It would be great to develop these as a matched pair against the current
server.schema.json. We are happy to contribute the registry-side diff implementation and share our drift dataset (the 10 Oct dump) as test fixtures, and to align our emitted shape to whatever the maintainers prefer.Three things that are genuinely maintainers' calls: (1) a field on the version object vs a dedicated
.../versions/{v}/driftendpoint; (2) compare-to-previous, compare-to-first, or both; (3)_meta-namespaced vs top-level, to keep the core schema stable. Glad to open the implementation PR once the shape and placement are settled.peach420fuzz commentedon Oct 27, 2026
Thanks @bl014h — the concrete shape and offer of a matched-pair fixture set are useful. I have turned our offline client-gate cases into a 20-case Registry/client acceptance matrix (affiliation: objekts Production Lab; source review + synthetic Node tests, not a Registry implementation or live deployment).
Two schema/contract details from the current OpenAPI seem worth settling before we lock the JSON shape:
ServerResponse = {server, _meta}._meta["io.modelcontextprotocol.registry/official"]currently hasadditionalProperties: false. If maintainers choosetargetDriftin that Registry-owned namespace, that needs an explicit OpenAPI property/schema change. I would avoidserver._meta, which is the publisher-side extension surface: a computed Registry observation needs an unambiguous provenance boundary.comparedTo.previousVersionshould state which chronology determines “previous” (published/ingested), including a nonsemver version fixture. Otherwise two consumers can disagree without either doing the URL comparison incorrectly.I also made explicit two intentionally different query cases: query-key-set changes are safe for a public Registry signal without revealing values, but a same-key query-value rotation can still change the locally pinned exact target. Thus Registry
remotesChanged: falsemust never suppress client re-consent. The same goes for a same-scope package rename, or tools-only drift. Missing drift metadata remainsUNKNOWN, not unchanged.If a maintainers-approved response location + first/previous comparison policy emerges, I can adapt the existing 42-case offline client review suite to consume the proposed Registry A/B fixtures and publish mismatches as reproducible cases. Happy to compare the public sanitized 10-Oct server.json pair fixtures when available; no raw token-bearing URLs or sensitive tool arguments needed.
bl014h commentedon Oct 27, 2026
@peach420fuzz both schema points are right and tighten the shape. Adopting:
1. Provenance. Agreed — the signal belongs in the Registry-owned
_meta["io.modelcontextprotocol.registry/official"]as a Registry-computed observation, with the OpenAPI change that implies (it isadditionalProperties: falsetoday), notserver._meta(the publisher surface,additionalProperties: true, where "no drift" could be self-asserted). Keeping it on the registry side of that boundary is the point.2. "Previous" chronology. Also right — versions sort as text (openapi.yaml L28). The clean definition is already in the schema: publication date —
GET /servers/{id}/versionsis ordered by publishedAt (L87) andpublishedAtis first-class (L1055). SocomparedToshould namepreviousPublishedandfirstby publishedAt, with a non-semver fixture to prove the text-sort trap cannot leak in.The invariant that matters most:
remotesChanged: falsemust never suppress client re-consent. A same-key query-value rotation, a same-scope package rename, and tools-only drift are all invisible to a server.json-only signal — so the registry signal is explicitly necessary, not sufficient: a cheap shared hint that lets a client skip re-diffing history, never a substitute for the client re-pinning against its own exact target and live tool contract. Missing metadata isUNKNOWN, never "unchanged." Worth writing that into whatever lands, so no one mistakes the hint for the gate.Fixtures: yes — we will publish a sanitized 10-Oct
server.jsonA/B set (key-set change, value rotation, same-scope rename, non-semver ordering, tools-only drift, and an UNKNOWN case), no token-bearing URLs or tool arguments, so your 42-case suite and a registry reference diff can consume the same pairs and we can publish mismatches as reproducible cases.That leaves two things that are genuinely maintainers' calls, and everything else we can prototype against fixtures now:
_metanamespace + the OpenAPI schema change that requires), andpublishedAt, and/orfirst).For the maintainers: is a Registry-computed
targetDriftin the official_metanamespace something you would entertain in principle? If so, we will put up the fixtures and a reference diff against the currentserver.schema.json.research: target-drift matched-pair fixtures (registry#1706)
bl014h commentedon Oct 27, 2026
Fixtures are up: research/listing-swap/fixtures.
Six sanitized, fully synthetic
server.jsonA/B pairs (no real publishers, no token-bearing URLs, no tool arguments), each with anexpected.jsongiving the RegistrytargetDriftsignal and the required client action:remotesChanged: trueremotesChanged: falsepackageOwnersChanged: falseremotesChanged: truefalseUNKNOWNcomparedTois resolved bypublishedAt(not the text version sort), remotes compare query keys not values, and a self-check enforces the invariant that no all-falseRegistry signal ever auto-passes the client.@peach420fuzz — these should drop straight into your matched-pair suite. If the shape of
expected.jsondoes not line up with your 42-case format, say so and I will adjust the emitter; the point is that we run the same pairs and publish any mismatch. Glad to add cases as we find them.Open-source the MCP registry scanner under Apache-2.0
peach420fuzz commentedon Oct 28, 2026
Thanks for publishing the six synthetic A/B pairs. I replayed all six against the actual
objektsclient-side target guard on Node 24.22.1 (not a simulation of the Registry implementation): test receipt and caveats.Observed: 01–04 =>
REVIEW_REQUIREDwith target granularity (new query keys / value-only query rotation / same-scope package-identifier swap / remote-origin swap); 06 =>UNVERIFIEDwithno_approved_baseline. All six returnedrequiresReview: true; none silently passed. In 02 the client deliberately sees exact query-value rotation although the proposed RegistryremotesChangedstays false — useful separation of responsibilities.The replay also exposed a real ambiguity on our side: one broad diagnostic label covered both query-key addition and value rotation. We fixed that while retaining its legacy signal (
remote_query_key_set_changedvsremote_query_string_changed) and reran 45/45 tests: guard / tests.Two fixture limitations to make explicit:
a.server.json,b.server.jsonand a prosetools_list_note. With identical synthetic tool arrays, our guard reports onlypublished_version_changed(correct for those inputs); the declaredtools/listchange cannot be tested against the published pair. In a separate illustrative synthetic replay I supplied a changedread_docannotation plus newsend_emailtool; the guard then reportedtool_contract_changed+tool_added, as expected. Would you add executablea.tools.json/b.tools.jsonarrays for Update README to reflect binary name change #5?publishedAtselection versus text-version ordering: to test that algorithm, we'd need at least one additional historical record with misleading version ordering and explicit timestamps.Our tool is an offline review signal, not consent enforcement; the
expected.client_actionassertions would require a separate client policy oracle. None of this asserts the proposal is merged or a deployed Registry feature. Happy to repeat the comparison once tool-list and chronology fixtures are available.bl014h commentedon Oct 28, 2026
@peach420fuzz first off — thank you. Genuinely. You didn't have to stand up a real client guard, replay all six pairs on Node 24, and write up exactly what each one did, but you did, and that's the kind of thing that makes an idea in an issue thread actually trustworthy. Seeing it run against your guard and not just our own reference diff means a lot.
And your results are the ones I was hoping for: 01–04 landing
REVIEW_REQUIREDwith target granularity, 06 coming backUNVERIFIEDwith no approved baseline, and — the part I care about most — all six raisingrequiresReview, none of them quietly passing. That "nothing silently passes" property is the whole reason this exists, so having an independent implementation confirm it is reassuring in a way I can't overstate. Nice work tracking down that diagnostic-label split, too; 45/45 is a good feeling.I also really appreciate how you framed your tool: an offline review signal, not consent enforcement. That's not a smaller claim, it's the honest one, and it's the same line we're trying to hold from the registry side — the registry hint is there to let a client skip re-diffing history, never to stand in for the client re-pinning against its own target and re-consenting. We're describing the same boundary from two directions. That's useful context, and it's how I'd want it read.
Here are the two things you asked for.
1. Case 05 — executable
tools/listarraysYou're right that 05 is the awkward one: the drift is invisible in
server.jsonbecause the target is byte-identical, so it only lives in the client-observedtools/list. The pair now carries the arrays right next to the server.json atresearch/listing-swap/fixtures/05-tools-only-drift/.a.tools.json(baseline):[ { "name": "read_doc", "description": "Return the text of a document by id.", "inputSchema": { "type": "object", "properties": { "doc_id": { "type": "string", "description": "Opaque document id." } }, "required": ["doc_id"], "additionalProperties": false }, "annotations": { "title": "Read document", "readOnlyHint": true, "openWorldHint": false } } ]b.tools.json(current):[ { "name": "read_doc", "description": "Return the text of a document by id.", "inputSchema": { "type": "object", "properties": { "doc_id": { "type": "string", "description": "Opaque document id." } }, "required": ["doc_id"], "additionalProperties": false }, "annotations": { "title": "Read document", "readOnlyHint": false, "openWorldHint": false } }, { "name": "send_email", "description": "Send an email to a recipient.", "inputSchema": { "type": "object", "properties": { "to": { "type": "string", "description": "Recipient address." }, "subject": { "type": "string" }, "body": { "type": "string" } }, "required": ["to", "body"], "additionalProperties": false }, "annotations": { "title": "Send email", "readOnlyHint": false, "destructiveHint": true, "openWorldHint": true } } ]The delta your guard should catch:
send_emailis new (write-capable —destructiveHint+openWorldHint), andread_doc'sreadOnlyHintflippedtrue → falseunder an unchangednameandinputSchema. Both should force review, and none of it is derivable fromserver.json— that's the point of the case. I checked in the expected delta astools.expected.jsonso you've got something to assert against.2. Version-ordering record
This one I had fun with. It's a synthetic
GET /servers/{name}/versionsfor a single server, atresearch/listing-swap/fixtures/07-version-ordering-record/versions.json, and it's listed in descending version-string order on purpose — to bait anything that trusts the semver/text sort instead ofpublishedAt:{ "servers": [ { "name": "io.example.fixtures/zeta", "version": "2.0.0", "remotes": [ { "type": "streamable-http", "url": "https://api.zeta-new.example/mcp" } ], "_meta": { "io.modelcontextprotocol.registry/official": { "publishedAt": "2026-01-01T00:00:00Z" } } }, { "name": "io.example.fixtures/zeta", "version": "1.5.0", "remotes": [ { "type": "streamable-http", "url": "https://api.zeta-new.example/mcp" } ], "_meta": { "io.modelcontextprotocol.registry/official": { "publishedAt": "2026-03-01T00:00:00Z" } } }, { "name": "io.example.fixtures/zeta", "version": "1.4.0", "remotes": [ { "type": "streamable-http", "url": "https://api.zeta.example/mcp" } ], "_meta": { "io.modelcontextprotocol.registry/official": { "publishedAt": "2026-02-01T00:00:00Z" } } } ] }The two orderings disagree about which record is current and which is previous, and — this is the part worth catching — the verdicts flip:
publishedAt(correct): current is1.5.0(Mar 1), previous is1.4.0(Feb 1) → originapi.zeta.example → api.zeta-new.example→remotesChanged: true, re-consent.2.0.0, previous1.5.0— both already onapi.zeta-new.example→remotesChanged: false. A dangerous silent pass. It compared the wrong two records and hid a real origin change.So the assertion for your suite: resolve current + previous by
publishedAtand you gettrue; sort by version string and you getfalseon the identical record. If a guard ever reportsfalsehere, it's ordering by the wrong field. Full breakdown is inexpected.json.If the pair keeps turning up mismatches between a registry diff and a client gate, please keep filing them — those reproducible disagreements are the most valuable thing this thread can produce, and honestly it's a pleasure building this out with someone who actually runs the cases. Thank you again.