Skip to content

fix(publisher): record repository.id at init so renames stay resolvable - #1570

Open
UgaTheDev wants to merge 2 commits into
modelcontextprotocol:mainfrom
UgaTheDev:fix/publisher-record-repository-id
Open

fix(publisher): record repository.id at init so renames stay resolvable#1570
UgaTheDev wants to merge 2 commits into
modelcontextprotocol:mainfrom
UgaTheDev:fix/publisher-record-repository-id

Conversation

@UgaTheDev

Copy link
Copy Markdown

Towards #1484

Background

#1484 audited the repository.url of the 398 highest-graded remote servers and
found 38 (9.5%) pointing at repositories that have since been renamed or
transferred. No hard 404s — GitHub's REST API follows the redirect — but GraphQL
consumers resolve them as nonexistent (Could not resolve to a Repository), so
the source link is broken for a real class of tooling, and it stays broken once
the redirect eventually lapses.

The gap this closes

pkg/model/types.go already defines the field that solves this:

ID string `json:"id,omitempty" doc:"Repository identifier from the hosting service (e.g., GitHub repo ID). Owned and determined by the source forge. Should remain stable across repository renames and may be used to detect repository resurrection attacks …"`

Nothing populates it. mcp-publisher init builds the repository block from
detectRepoURL() and writes url and source only, so in practice every entry's
sole pointer at its source is a URL — and a URL is not a stable pointer.

This PR resolves the ID at init time and writes it alongside the URL. A rename
after that point no longer loses the entry: the ID still identifies the
repository, and it also gives the "deleted and recreated" detection the schema
doc already describes something to compare against.

Scope and deliberate limits

  • One request, at scaffolding time. Not in the publish path, so it adds no
    latency or GitHub rate-limit exposure to publishing.
  • Best effort. init is otherwise fully offline. Being off the network,
    rate limited, or pointed at a private repo leaves the field empty rather than
    failing the command. GITHUB_TOKEN is used when set, mostly so a rate-limited
    developer still gets an ID.
  • GitHub only. repository.source also allows gitlab, and detectRepoURL
    can produce a plain git source. Those return empty; GitLab's equivalent
    lookup is easy to add if you want it, but I did not want to guess at the
    ID semantics for a forge the audit did not cover.
  • Host matching is exact (github.com, optionally www.) so a lookalike host
    cannot get us to fetch an ID that belongs to somebody else. Covered by a test.

What this does not do

Two parts of #1484 are maintainer calls, not code I should pick unilaterally, and
I have written them up on the issue rather than guessing here:

  1. Backfilling the 38 existing entries. The registry's data is in the
    database, not this repo — CLAUDE.md is explicit that data/seed.json is
    local dev seed data and must not be used to publish. So a data fix cannot
    arrive as a PR to this repository at all; it needs an operator-run migration,
    and someone has to decide whether rewriting a publisher's repository.url
    without them re-publishing is acceptable.
  2. Publish-time behaviour on a redirecting URL — reject, warn, or accept and
    record. ValidateServerJSON is currently pure and offline; the only network
    validation lives in ValidatePublishRequest behind
    cfg.EnableRegistryValidation, and it returns a bare error with no channel
    for a warning even though ValidationIssueSeverityWarning exists elsewhere.
    Wiring a warning through is a real design decision about the publish contract.

Test

cmd/publisher/commands/init_internal_test.go:

  • TestParseGitHubOwnerRepo — the URL shapes detectRepoURL actually produces
    (plain, www., trailing slash, .git), plus the ones that must not be
    treated as GitHub: gitlab, a github.com.evil.example lookalike, a deep
    /tree/main/... path, an owner with no repo.
  • TestDetectRepoID — against an httptest stub: records the numeric id and
    requests /repos/{owner}/{repo}; and stays empty on 404 / non-GitHub source /
    empty URL, so a failed lookup can never fail init.
$ go test ./cmd/publisher/...
ok      github.com/modelcontextprotocol/registry/cmd/publisher/auth      2.363s
ok      github.com/modelcontextprotocol/registry/cmd/publisher/commands  4.838s

$ go build ./...      # clean
$ gofmt -l cmd/       # clean

golangci-lint was not available in my environment, so that gate is unverified
locally.

`repository.id` is documented in the schema as the forge's own identifier,
stable across renames and usable to detect a repository being deleted and
recreated. Nothing ever populates it: `mcp-publisher init` writes only `url`
and `source`, so in practice every entry's only pointer at its source is a
URL.

A URL is not a stable pointer. Renaming or transferring a GitHub repository
leaves the registered URL resolving only through GitHub's redirect — which
the REST API follows but GraphQL does not, so GraphQL consumers see
"Could not resolve to a Repository" and the source link is effectively
broken. modelcontextprotocol#1484 measured this across the 398 highest-graded remote servers:
38 of them (9.5%) already point at renamed or transferred repos.

Resolve the ID at init and write it alongside the URL. One request, at
scaffolding time rather than in the publish path, and the entry stays
re-resolvable afterwards even once its URL has gone stale.

Deliberately best effort: `init` is otherwise fully offline, so being off
the network, rate limited, or pointed at a private repo leaves the field
empty rather than failing the command. `GITHUB_TOKEN` is used when set.

This stops new entries from rotting. It does not backfill the 38 already
affected, and it does not decide whether publish should reject or warn on a
URL that redirects — both need a maintainer call, raised on the issue.

Signed-off-by: Kush Zingade <kush.zingade@gmail.com>

@JosephDoUrden JosephDoUrden left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran this locally on 433606c (currently 0 behind main): go test ./cmd/publisher/... all green. The table tests cover the shapes I would worry about — lookalike hosts, deep paths, non-GitHub sources, non-200 responses all leave the field empty instead of failing init. Best effort is the right call for a command that is otherwise fully offline, and the id recorded for a real repo matches what GET /repos/{owner}/{repo} returns.

One thing I could not reproduce from the motivating audit (#1484): the claim that GraphQL consumers fail to resolve renamed repos. All five renamed entries in the audit's table resolve fine via GraphQL today, following the rename:

repository(owner:"PayRam", name:"payram-helper-mcp-server")  -> PayRam/payram-mcp
repository(owner:"cgallic", name:"kaicalls-mcp")             -> KaiCalls/kaicalls-mcp
repository(owner:"Airpote", name:"avalanche-mcp-vscode")     -> yassirboudda/avalanche-mcp-vscode
repository(owner:"lastmanupinc-hub", name:"Toolbox")         -> lastmanupinc-hub/AXIS-iliad
repository(owner:"vbkotecha", name:"aiservices-api")         -> vbkotecha/agentservices-api

Could be a token-scope artefact on the audit side, or GitHub behaviour changed since the July run — a question for the audit rather than a problem with this PR. Worth noting the audit's "0 hard 404s" headline also sits oddly next to the 33 rows in its own table that are hard 404s; only the 5 above are actual renames.

That does not change the verdict here. The schema defines repository.id precisely because a URL is not a stable pointer, and the numeric id survives renames and transfers — recording it at init is justified regardless of how any one consumer handles redirects.

Looks merge-ready to me.

@Santoshkumarpuppala

Copy link
Copy Markdown

Agreeing with the review that best effort is the right call for a command that is otherwise fully offline. One consequence worth settling deliberately before this merges, because it lands on the field's stated purpose rather than on init.

detectRepoID returns the empty string for every failure it can reach — non-GitHub source, lookalike host, deep path, network error, non-200 (private repo, rate limit), decode failure. The field is json:"id,omitempty", so none of those produce an empty id; they produce no field at all, which is byte-identical to an entry published before this lands.

The doc comment says the field "may be used to detect repository resurrection attacks — if a repository is deleted and recreated, the ID should change". Whatever implements that check later reads an absent repository.id and cannot separate a GitLab entry, where absence is correct, from a GitHub entry whose publisher happened to be rate limited the one time they ran init. init runs once; there is no retry, and a backfill that resolves drifted URLs would not catch these, because they are not drifted.

The cheap fix is probably not schema. One line on stderr — "could not resolve repository.id (rate limited); publishing without it" — makes the gap visible at the one moment a human is present. Plus a test that points the stub at a 403 and asserts the outcome is distinguishable from the non-GitHub case, rather than asserting only that both yield an empty id.

Same shape one layer down, for what it is worth: pinning a tool definition keyed on its name is defeated by a rename, because the renamed tool has no prior pin and registers as new rather than changed. Immutable IDs are the fix in both places. That is Norviq, which I maintain — https://github.com/norviq-dev/norviq.

@JosephDoUrden

Copy link
Copy Markdown
Contributor

checked this against the code and the ambiguity is real. pkg/model/types.go:57 has the field as json:"id,omitempty" and detectRepoID returns "" on every failure path, so a rate-limited init and a gitlab entry really do serialize identically. worth adding though that absence is also the state of every entry published before this PR, so whatever implements the resurrection check has to treat absent as unknown regardless, not as verified-absent. the ambiguity predates the field.

which is why I'd keep the verdict as is. the field is best effort, absent-means-unknown is already forced by history, and blocking on that would be blocking on the backlog. the stderr line is a good cheap addition though, and the 403-vs-non-github test with it. works as a fast follow, or here if UgaTheDev prefers.

…r GitHub sources

`repository.id` is `omitempty`, so a GitHub `init` that could not resolve the ID
(rate limited, private repo, offline) serialized identically to a GitLab entry
where the field's absence is correct. The gap was silent.

Have `detectRepoID` return why the lookup produced nothing and let `init` print
one stderr warning naming the reason, so a human present at scaffolding time can
see the field was attempted and missed. The field stays best effort: `init` still
succeeds, and non-GitHub sources return no error and stay silent.

Extends the existing `detectRepoID` table test to cover 403 (rate limited)
alongside 404, asserting the GitHub failure is distinguishable from the
non-GitHub case rather than only that both yield an empty ID.

Addresses the review discussion on modelcontextprotocol#1570.

Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
@UgaTheDev

Copy link
Copy Markdown
Author

Good catch on the indistinguishable case: with id,omitempty, a rate-limited GitHub init serialized exactly like a GitLab entry where the empty field is correct, so the miss was invisible.

Added a follow-up commit to this PR. detectRepoID now returns why a GitHub lookup came back empty, and init prints one stderr line, warning: could not resolve repository.id (<reason>); publishing without it, then writes server.json as before. Non-GitHub sources return no error and stay silent, and the field remains best effort: init never fails on a failed lookup.

The existing detectRepoID test now covers 403 alongside 404 and asserts the GitHub failure carries a reason while the GitLab case does not, so the two are distinguishable rather than both just being an empty id.

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.

3 participants