fix(publisher): record repository.id at init so renames stay resolvable - #1570
fix(publisher): record repository.id at init so renames stay resolvable#1570UgaTheDev wants to merge 2 commits into
Conversation
`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>
There was a problem hiding this comment.
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.
|
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
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 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. |
|
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>
|
Good catch on the indistinguishable case: with Added a follow-up commit to this PR. The existing |
Towards #1484
Background
#1484 audited the
repository.urlof the 398 highest-graded remote servers andfound 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), sothe 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.goalready defines the field that solves this:Nothing populates it.
mcp-publisher initbuilds the repository block fromdetectRepoURL()and writesurlandsourceonly, so in practice every entry'ssole 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
latency or GitHub rate-limit exposure to publishing.
initis 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_TOKENis used when set, mostly so a rate-limiteddeveloper still gets an ID.
repository.sourcealso allowsgitlab, anddetectRepoURLcan produce a plain
gitsource. Those return empty; GitLab's equivalentlookup 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.
github.com, optionallywww.) so a lookalike hostcannot 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:
database, not this repo —
CLAUDE.mdis explicit thatdata/seed.jsonislocal 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.urlwithout them re-publishing is acceptable.
record.
ValidateServerJSONis currently pure and offline; the only networkvalidation lives in
ValidatePublishRequestbehindcfg.EnableRegistryValidation, and it returns a bareerrorwith no channelfor a warning even though
ValidationIssueSeverityWarningexists 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 shapesdetectRepoURLactually produces(plain,
www., trailing slash,.git), plus the ones that must not betreated as GitHub: gitlab, a
github.com.evil.examplelookalike, a deep/tree/main/...path, an owner with no repo.TestDetectRepoID— against anhttpteststub: records the numeric id andrequests
/repos/{owner}/{repo}; and stays empty on 404 / non-GitHub source /empty URL, so a failed lookup can never fail
init.golangci-lintwas not available in my environment, so that gate is unverifiedlocally.