Skip to content

Feature request: surface target changes between server versions (listing swaps) #1706

Open

Description

@bl014h

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:

  1. 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.
  2. 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.

Activity

  1. kaspermuutra-dotcom commented on Oct 20, 2026

    @kaspermuutra-dotcom

    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

  2. peach420fuzz commented on Oct 26, 2026

    @peach420fuzz

    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.

  3. bl014h commented on Oct 27, 2026

    @bl014h
    Author

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

  4. bl014h commented on Oct 27, 2026

    @bl014h
    Author

    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:

    "_targetDrift": {
      "comparedTo": { "firstVersion": "0.1.0", "previousVersion": "2.3.0" },
      "remotesChanged": true,
      "packageOwnersChanged": false,
      "repositoryChanged": false,
      "changes": [
        { "kind": "remote", "field": "url.origin",
          "from": "mcp.example.com", "to": "mcp.other-host.net" }
      ]
    }

    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.

  5. peach420fuzz commented on Oct 27, 2026

    @peach420fuzz

    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:

    1. 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.
    2. 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.

  6. bl014h commented on Oct 27, 2026

    @bl014h
    Author

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

  7. bl014h commented on Oct 27, 2026

    @bl014h
    Author

    Fixtures are up: research/listing-swap/fixtures.

    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.

  8. peach420fuzz commented on Oct 28, 2026

    @peach420fuzz

    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:

    1. 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?
    2. 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.

  9. bl014h commented on Oct 28, 2026

    @bl014h
    Author

    @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 }
      }
    ]

    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_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:

    {
      "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:

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions