Repository navigation
plan: v2 go-live runbook — deprecate v1 (1.0.1), branch v1/main, swap main to v2, publish 2.0.0, close v1 backlog, restrict external PRs #1804
Description
Activity
This all makes sense to me.
One note: if we switch steps 1 and 2, then we could validate publishing from the v1/main branch early, and if if fails, easily get back to where we started (just delete the v1/main branch).
Reacted by Cliff Hall- added a commit that references this issue
on Jul 27, 2026 - added sub-issues
on Jul 28, 2026 - added a commit that references this issue
on Jul 28, 2026 State of the runbook: Phase 1 complete
Phase 1 (#1824) is done — all four steps executed and verified. Nothing published, nothing irreversible. Phase 2 (#1815
⚠️ ) is next and is the first irreversible one.What now exists
Thing State v1/mainbranch3188ee3a= tag1.0.0(ac3c1a12) + one commit,add v1/main to branches in main.ymlCI on v1/mainpush:trigger now includesv1/main;pull_request:was already unfiltered (verified, not edited)Branch ruleset 19858989v1/main - full protection— active,refs/heads/v1/mainTag ruleset 19859230v1 release tags— active,refs/tags/1.*, rulesdeletion+updateThe branch ruleset was cloned from
3187937and diffed against it after creation:rulesandbypass_actorsare byte-identical, including thebuildcheck'sintegration_id: 15368and the fullpull_requestparameter block. Sov1/mainis protected exactly asmainis — no transcription drift.Rules confirmed to actually resolve on the branch (not merely to look right in the ruleset body):
$ gh api 'repos/modelcontextprotocol/inspector/rules/branches/v1%2Fmain' --jq '.[].type' deletion non_fast_forward pull_request required_status_checksStep 2 was merged before the ruleset went on, per the runbook, so
buildhas registered on the branch and the first protected PR can't deadlock on a check that has never run.Three things worth carrying into phase 2
1. The §10 protection requirement is satisfied, but admins still bypass it. The cloned
bypass_actorsincludeRepositoryRole 5(admin) atbypass_mode: always. So §4's "anyone who can push tov1/maincan publish the v2 package name" still holds for every repo admin — the ruleset closes the unguarded parking branch hole, not the admin hole. That matchesmain's posture and is what "clone the existing ruleset" asked for; flagging it so nobody reads phase 1 as having closed it.A practical consequence: do not verify protection by attempting a direct push. As an admin it succeeds, proves nothing, and writes to
v1/main— andnon_fast_forwardthen blocks a clean undo. #1824's checklist has been corrected to use the API check above instead.2. The tag ruleset has no bypass actors — deliberately stricter than the branch. Nobody, admins included, can move or delete a
1.xtag without first disabling the ruleset.creationis absent, so phase 2 can still cut1.0.1normally. But if phase 2 needs to retag — e.g. the release is cut against the wrong commit — that will be blocked, and the fix is to disable19859230, retag, re-enable. Worth knowing before you're mid-release wondering why the tag won't budge.3. There is no
/rules/tags/API. GitHub exposes/rules/branches/{branch}only; the tag equivalent 404s. Tag rulesets can be verified only by reading the ruleset back:gh api repos/modelcontextprotocol/inspector/rulesets/19859230 \ --jq '{target, enforcement, include: .conditions.ref_name.include, rules: [.rules[].type]}' # → tag, active, ["refs/tags/1.*"], ["deletion","update"]
Ground truth unchanged
Nothing in the §"Ground truth" table has moved: npm
latestis still 1.0.0, no other dist-tags exist, andmainis untouched at 1.0.0. Thereleaseenvironment is still No restriction, so a1.0.1tag onv1/mainwill reach the publish job — which is what makes phase 2 a real OIDC rehearsal rather than a blocked run.Next
Phase 2 (#1815)
⚠️ — the first irreversible phase. The runbook's own cautions that still apply verbatim: bump all fourpackage.jsons to 1.0.1 orcheck-versionfails; do not add--tag v1yet (1.0.1 must takelatest); and watch the partial-publish hazard inpublish-all(workspaces succeed, root fails → recover by bumping to 1.0.2).#1824 stays open until someone signs off on the above; say the word and I'll close it and move the card to Done.
- added 4 commits that reference this issue
on Jul 28, 2026 State of the runbook: Phases 2 and 3 complete
3 of 9 done — #1824, #1815, #1816 all closed, cards on Done. Next is #1817 (replace
main's tree with v2).Where things stand
State npm latest1.0.1 (was 1.0.0) npm v1-latest1.0.1 — new escape hatch, all four packages Deprecated on npm all four names at @<2.0.0v1/main47f92841, ruleset-protected, CI greenpublish-allonv1/mainpinned --tag v1-latestmainuntouched, still v1 at 1.0.0 Phase 2 (#1815) — the OIDC rehearsal passed
Trusted publishing works from a non-default branch. npm matched on repo + workflow filename +
releaseenvironment with no branch or ref pinning, exactly as §4 predicted from the docs — now evidence rather than a reading. Thereleaseenvironment let it through with no approval pause, and--provenancewas fine (workflow ref and published tree are the same commit).So the
workflow_dispatchfallback in §4 is not needed. Step 7 of the plan is viable as written.1.0.1 published cleanly — all four packages, no partial publish. Verified from the published tarball, not just registry metadata: the deprecation banner is present in the shipped
cli/build/cli.js.One scope change worth recording: §2 said "README + runtime banner", which understated it. v1 publishes four separately installable names, so a root-README-only notice is invisible to anyone who installed a sub-package — and
cli/andserver/ship no README at all, so npm renders a blank page for both. For those two, the runtime banner and the npm deprecation are the only notice their users ever get. All four now carry one, plus a dismissible in-app banner in the client.Phase 3 (#1816) — two runbook errors found
Both would have surfaced later and worse.
1.
v1is not a legal npm dist-tag. npm rejects any tag that parses as a SemVer range, andv1parses as1.x:npm error Tag name must not be a valid SemVer range: v1§1 specifies
v1in two places. Beyond failingnpm dist-tag add, the--tag v1merged in #1828 would have made the next v1 security release fail to publish outright — at release time, mid-incident, which is the worst possible moment to discover it. The tag is nowv1-latest(#1829), and the workflow comment records both failure modes so neither the flag nor the specific name gets "simplified" later.2. The
<=1.0.0sub-package range was stale. It was written when 1.0.0 was their newest version. Phase 2 published 1.0.1 to all three, so<=1.0.0would have left the versionlatestresolves to — the one everyone actually installs — un-deprecated, making three orphaned packages look healthy. Now<2.0.0for all four, which is safe since no 2.x will ever exist for the sub-packages.Operational note:
npm deprecatehad to be run from a script file, not a pasted command. A long pasted line gets hard-wrapped by the terminal, the newline is stored verbatim in the registry, and npm truncates its warning at the break — hiding the upgrade pointer. It took two attempts to spot; the tell was the break position moving with message length. Anyone re-running it should build the string in a file.Carried forward
- The admin-bypass hole from phase 1 is still open, and now demonstrated. Both v1 PRs merged via
--admin, becauserequire_code_owner_review: trueis set onv1/mainwith no CODEOWNERS file on the branch — a required check nothing can satisfy. That is worse than no check: it looks like protection while forcing every merge through bypass, which normalizes bypassing. Worth resolving before go-live 4/9: replace main's tree with v2 #1817 puts the same ruleset shape in front ofmain. - §1 and §3 of this issue still contain the wrong
v1tag name and<=1.0.0range. go-live 3/9: lock the v1 dist-tag and deprecate all four packages on npm #1816's body has been corrected; this umbrella's text has not. - The first real v1 security release remains the first true exercise of the
--tag v1-latestpath — phase 3 verified the flag is present and the tag name is legal, not that a publish through it succeeds.
- The admin-bypass hole from phase 1 is still open, and now demonstrated. Both v1 PRs merged via
- added a commit that references this issue
on Jul 28, 2026 State of the runbook: Phase 4 complete
4 of 9 done.
mainis now the v2 tree. Next is #1818 (publish 2.0.0)⚠️ .State mainhead84456758(swap merge25106dcc+ two follow-ups)mainversion2.0.0 History on main2405 commits — both lineages intact Open PRs on main0 Open CodeQL alerts 0 npm lateststill 1.0.1 — nothing published this phase The swap (#1830)
Landed as a merge commit with two parents (
ac3c1a12= oldmain,4d30d1cd=v2/main), not a squash. That was load-bearing: squashing would have dropped the second parent and made 2381 v2 commits unreachable frommain.git diff HEAD origin/v2/mainwas empty before commit — the tree is byte-identical tov2/main, 1216 files.Verified after merge: old
mainis still an ancestor, all ofv2/mainis reachable, root version reads 2.0.0, and CI onmainpassed on the first run against the swapped tree.Three corrections to the plan
1. §5's merge command fails as written.
fatal: refusing to merge unrelated historiesmainandv2/mainhave genuinely unrelated histories — different root commits (996f02c7vs3b583e1f), no merge base at all.--allow-unrelated-historiesis required. This follows from something AGENTS.md already notes (v1.5 was never an ancestor of v2/main), but §5 did not account for it.The flag is only a guardrail — the resulting commit is identical either way. What is permanent is the fact it surfaces:
mainnow has three root commits and two lineages that never touch. Practical consequences worth knowing:git log --followandgit blamewill not cross the boundary (v1'sclient/src/App.tsxand v2'sclients/web/src/App.tsxare unrelated files to git), and a plaingit log maininterleaves two timelines by date — use--first-parentto walk the oldmainline cleanly.2. The open-PR backlog was 125, not ~30.
gh pr listsilently caps at 30, which badly understated it. All 125 were re-pointed atv1/mainbefore the swap rather than left stranded against a tree that no longer exists —v1/mainstrictly descends frommain, so every merge base and diff is preserved (spot-checked #1732, #1696, #1519: all stillMERGEABLE). Triage remains #1819's job; this only preserved the option, and matters for that phase's rate-limit planning.3.
Closes #Nnow auto-closes. #1817 closed itself on merge — first time that has worked, since PRs intomainfinally target the default branch. The manual close-and-move-to-Done ritual can stop (#1821).Security findings surfaced by the swap
mainhad never scanned the v2 tree, so CodeQL reported 11 alerts at once. None were introduced by the swap — all pre-existing onv2/main.- 10 dismissed as used in tests:
test-servers/src/*andclients/web/src/test/*. Confirmed none are in the publishedfilesallowlist, so they never ship. Includes the 1 critical (js/request-forgeryin the OAuth test server) and 9 high. - 1 was not a test and was fixed (ci: set a least-privilege default GITHUB_TOKEN scope #1831):
actions/missing-workflow-permissionson.github/workflows/main.yml. Medium by rating, but it is the workflow that publishes to npm under OIDC — an over-permissionedGITHUB_TOKENin a job that mints publish credentials was worth closing before 2.0.0. Added a workflow-levelpermissions: contents: read; both publish jobs keep their own blocks, which override rather than merge — recorded in a comment, since a future edit trimmingcontents: readfrom a publish job "because it is inherited" would break publishing.
Dependabot — assessed, not a blocker
72 open alerts (3 critical / 36 high / 26 medium / 7 low). None gate 2.0.0: the release ships the same dependency tree
v2/mainhas always had. All 3 criticals are@vitest/browser, development scope, never shipped.One was worth real investigation — GHSA-frvp-7c67-39w9, path traversal in
@hono/node-server'sserve-static, because the Inspector calls that exact function in its prod server (serveStaticmounted at/*).⚠️ I initially assessed this as an unauthenticated arbitrary-file-read and recommended it block 2.0.0. That was wrong, and the correction matters: the advisory blocks..escapes, so it only reaches files inside the configured root, by slipping past prefix-mounted middleware. Hererootis the built SPA (public by design) and the only prefix middleware (/api/*) is a separate route, not a subdirectory of it — so there is no auth bypass and no arbitrary read, and it is Windows-only regardless.Upgraded anyway as hygiene (#1832), since it proved free: 1.x → 2.x drops Node 18 (repo needs ≥22.19.0) and removes the unused Vercel adapter;
serve/serveStatic/ServerType— the entire import surface here — are unchanged.npm run validategreen on all four clients.The rest are transitive; letting Dependabot's own PRs land after the release is the sane path.
Carried forward
- The admin-bypass hole is now on
main. chore: replace main's tree with v2 #1830, ci: set a least-privilege default GITHUB_TOKEN scope #1831 and chore(deps): upgrade @hono/node-server to 2.0.12 #1832 all merged with--admin, becauserequire_code_owner_review: trueis configured with no CODEOWNERS file — a required check nothing can satisfy. It now guards the default branch, where the next action is publishing 2.0.0. Worth resolving before go-live 5/9: publish 2.0.0 (rc under next first; rollback plan written first) #1818 rather than after. v2/mainis now redundant —mainis a strict superset. Anything merged there from here on will not be onmain. Retiring or fast-forwarding it belongs to go-live 8/9: post-swap docs, labels, boards, and integration hygiene #1821.- External
blob/main/...permalinks into the v1 tree now silently 404 (§10 anticipated this). Anything that must keep pointing at v1 should be re-pinned to the1.0.1tag orv1/main.
- 10 dismissed as used in tests:
State of the runbook: Phase 5 complete — v2 is live
5 of 9 done. The irreversible half is finished; everything remaining is reversible cleanup.
latest 2.0.0 ← was 1.0.1 v1-latest 1.0.1 next 2.0.0-rc.3npx @modelcontextprotocol/inspectornow resolves to v2 worldwide.State npm latest2.0.0 npm v1-latest1.0.1 (escape hatch intact) Docker latestdigest identical to 2.0.0, multi-archDocker 1.0.1intact, own digest — v1 pin main7aebf168, v2 tree, 2.0.0v1/main47f92841, protected, publishes underv1-latestOpen Dependabot alerts 0 Open CodeQL alerts 0 The RC process was not ceremony
The v2 publish job had never executed — every prior release was cut from the v1 workflow. So every defect below was sitting there waiting for whoever cut the first v2 release, and two would have failed 2.0.0 outright:
ENEEDAUTH—NODE_AUTH_TOKENpointed at aNPM_TOKENsecret that does not exist (publishing moved to OIDC trusted publishing).setup-nodestill wrote an empty_authTokeninto its.npmrc, so npm failed before OIDC was ever attempted. (fix(ci): publish via OIDC trusted publishing, not a non-existent NPM_TOKEN #1836)- No npm CLI upgrade — trusted publishing requires npm >= 11.5.1; Node 22 bundles 10.x. Would have failed even after fixing the above. The v1 workflow has this step; v2 was missing it. (fix(ci): publish via OIDC trusted publishing, not a non-existent NPM_TOKEN #1836)
- No
--tagon publish —npm publishdefaults tolatestregardless of semver prerelease status, so §6's own instruction to publish2.0.0-rc.1would have pointedlatestat a release candidate. Caught while preparing the RC; the RCs then proved the fix. (ci: derive the npm dist-tag from the version instead of defaulting to latest #1834)
Each RC was validated by installing the published tarball into a cold
node:22container and driving it end to end — install,--help, and a real--cli tools/listagainst a live stdio server.lateststayed on 1.0.1 through all three, which is what proved the tag derivation worked.Three RCs, because the tree kept changing
The discipline held throughout: the tree that ships is the tree that was validated. Any dependency change invalidated the previous RC.
- rc.1 — caught defects 1 and 2 above.
- rc.2 — validated the dependency sweep (chore: clear all runtime dependency vulnerabilities; 2.0.0-rc.2 #1837): every prod-scope advisory cleared using lockfile-only changes, no
package.jsontouched, no ranges widened. They were all stale pins inside existing caret ranges. - rc.3 — validated the vite bump (fix(deps): raise vite to 8.1.5 to close three dev-server file-read advisories; 2.0.0-rc.3 #1841). The sweep had moved vite to 8.0.16 in root and tui but left
clients/webat 8.0.0;npm updatereported "up to date" and only an explicit install moved it. That left three high advisories —server.fs.denybypass and arbitrary file read via the dev server. Worth fixing rather than dismissing: unlike the other dev-scope alerts, those are remotely triggerable against anyone runningnpm run dev. Also raised the declared floor to^8.1.5, since root's^8.0.0still permitted the vulnerable version on a fresh resolve.
Corrections to this plan, found in phase 5
- §6's RC step was not achievable as written. There was no mechanism to pass a dist-tag through a release-triggered publish, and the default would have claimed
latest. Fixed in ci: derive the npm dist-tag from the version instead of defaulting to latest #1834; §6 should be read as depending on that. - The
1-pinned Docker tag §6 asks to confirm does not exist, and never did. The workflow tags by exact version only. Closed as satisfied by1.0.1, which is a working pin; a floating1would need adding tov1/main's workflow, not a one-off push, and adds an ongoing obligation to a deprecated line.
Deferred, tracked — not silently dropped
- chore(deps): migrate to eslint 10 to clear the brace-expansion DoS advisory #1838 — eslint 10 migration. Blocked:
eslint-plugin-react-hooks@7.0.1does not accept eslint 10, proven by Dependabot's own failed attempt (build(deps): bump the npm_and_yarn group across 5 directories with 2 updates #1840, closed). - chore(deps): untangle the vitest/Storybook exact-pin knot to clear GHSA-p63j-vcc4-9vmv #1839 — vitest/Storybook exact-pin knot. Blocked: circular exact peer pins,
npm ifails ERESOLVE.
Both dev-scope, neither shipped. Alerts dismissed with per-alert reasoning —
tolerable_riskfor vitest,not_usedfor esbuild (its dev-server mode is never invoked here; esbuild is reached only as a bundler via tsup and vite). The eslint cluster needed no manual action: GitHub auto-dismissed it, and #1838 records whynpm auditstill reports it.Carried forward
- Every merge in phases 1–5 went through
--adminbypass. The rulesets require an approving review the project cannot supply — GitHub forbids self-approval, sorequired_approving_review_count: 1is unsatisfiable for a single maintainer regardless of CODEOWNERS. Now guarding the default branch. Recorded on go-live 8/9: post-swap docs, labels, boards, and integration hygiene #1821 with three options; deliberately not changed mid-release. v2/mainis now redundant —mainis a strict superset. Anything merged there will not be onmain. Retiring it belongs to go-live 8/9: post-swap docs, labels, boards, and integration hygiene #1821.- go-live 7/9: triage and bulk-close the v1 PR backlog #1819 is 125 PRs, not ~30 —
gh pr listcaps at 30 by default. All were retargeted tov1/mainbefore the tree swap so their diffs survive. §8's secondary-rate-limit warning definitely applies at that size.
Remaining — all reversible
Phase Issue 6 #1819 ⚠️ Triage + bulk-close the v1 backlog (125 PRs) 7 #1820 Restrict PRs to collaborators; CONTRIBUTORS.md,SECURITY.md8 #1821 Post-swap docs, labels, boards, integration hygiene 9 #1822 v1 → v2 migration guide State of the runbook: Phase 6 complete — contribution model closed
6 of 9 done. Both irreversible-in-practice steps that have run (2.0.0 publish, contribution-model change) are behind us. One
⚠️ phase remains.Live state
npm latest 2.0.0 v1-latest 1.0.1 next 2.0.0-rc.3External pull requests closed — Collaborators only Open Dependabot alerts 0 Open CodeQL alerts 0 Open PRs on v1/main125 (awaiting phase 7 triage) Open PRs on main0 ⚠️ Phases 6 and 7 were swapped#1820 (restrict PRs) now runs before #1819 (triage the backlog). Closing the door first means the 125-PR triage happens against a set that cannot grow while it is being worked. Triaging first would have meant new external PRs arriving into a backlog being closed.
Issue titles, bodies, and the phase table above have been renumbered to match, each with a note recording the swap.
Phase 6 (#1820) — what it did
-
PRs → Collaborators only flipped. PR tab stays visible, anyone can read and comment, Issues unaffected.
-
CONTRIBUTORS.mdwas already onmain— it survived the tree swap. -
SECURITY.mdwas not, and that is the finding worth carrying: it existed on the pre-swapmain(ac3c1a12) and still exists onv1/main, but the v2 tree never had one — so go-live 4/9: replace main's tree with v2 #1817 silently dropped the security policy from the default branch. Nothing alerted on it; it surfaced only from checking this phase's prerequisites.The timing made it matter: the security-report route disappeared in the same window as the PR route being closed. Restored in docs: restore SECURITY.md, lost in the v2 tree swap #1843 with the supported-versions table §9 asks for, plus an explicit carve-out that the no-outside-PRs policy does not apply to security reports — without which the new model reads as "no way to reach us". Private vulnerability reporting verified enabled, so the advisory link is a working route.
⚠️ "Collaborators only" is broader than it sounds§9 asked us to confirm what "collaborator" resolves to. It is 110 accounts with push access — all org-inherited, zero direct, 30 of them admins.
So the setting restricts PRs to everyone with org-wide base write on
modelcontextprotocol, not to the people who maintain the Inspector. It closes the public drive-by firehose, which is the goal, but the correct mental model is "org members only". Tightening further is an org-level base-permission change, out of scope here.Corrections to the plan found in this phase
- §9's prerequisite was half-satisfied and nobody would have noticed. It says the docs must "land first" —
CONTRIBUTORS.mdhad,SECURITY.mdhad silently been deleted four phases earlier. - The tree swap (go-live 4/9: replace main's tree with v2 #1817) is a plausible source of other silent deletions.
SECURITY.mdwas found by accident. Anything that existed on the v1 tree and not the v2 tree is gone frommainwith no signal. Worth a deliberate diff during go-live 8/9: post-swap docs, labels, boards, and integration hygiene #1821 rather than waiting for the next accident.
Deferred work — filed, boarded, not implicit
Issue #1838 eslint 10 migration — blocked: eslint-plugin-react-hooksrejects eslint 10 (proven by Dependabot's own failed #1840)#1839 vitest/Storybook exact-pin knot — blocked: circular exact peers, npm ifails ERESOLVE#1844 Issue templates — split out of #1820; additive, since the policy docs are in place Carried forward
- Every merge across six phases went through
--adminbypass. The rulesets require an approving review the project structurally cannot supply (GitHub forbids self-approval, sorequired_approving_review_count: 1is unsatisfiable for a single maintainer regardless of CODEOWNERS). Options recorded on go-live 8/9: post-swap docs, labels, boards, and integration hygiene #1821. v2/mainis redundant —mainis a strict superset; anything merged there will not reachmain. Retirement belongs to go-live 8/9: post-swap docs, labels, boards, and integration hygiene #1821.- go-live 7/9: triage and bulk-close the v1 PR backlog #1819 is 125 PRs, not ~30. All were retargeted from
maintov1/mainbefore the swap, so diffs and merge bases are intact and the port to v2 vs. close as deprecated triage §8 calls for is still possible.
Remaining
Phase Issue 7 #1819 ⚠️ Triage + bulk-close the v1 backlog (125 PRs) — the last irreversible phase 8 #1821 Post-swap docs, labels, boards, integration hygiene 9 #1822 v1 → v2 migration guide -
Summary
Produce (and then execute) the plan for going live with v2 — making v2 the
mainbranch and thelatestnpm release, deprecating the v1 line while keeping it alive for security fixes only, and tightening the contribution model to issues-from-external-contributors.This issue is for writing and agreeing the plan. Each phase below should become its own tracked issue once the plan is signed off; nothing here should be executed ad hoc, since several steps are irreversible (npm publishes, bulk issue closure).
Phase issues
Filed on board #28 under the V2 Go Live column. Execute in order;⚠️ marks the irreversible ones.
v1/main, protect it, give it CI (§2, §10)v1/main— the OIDC rehearsal (§4)--tag v1-latestonpublish-all,v1-latestdist-tag,npm deprecateall four names (§1, §3)main's tree with v2 (§5)nextfirst, rollback plan written first (§6, §7)CONTRIBUTORS.md(#1517),SECURITY.md(§9)This umbrella issue stays open until all nine are Done.
Ground truth (re-verified 2026-07-28, after phases 1–3)
@modelcontextprotocol/inspectorlatest= 1.0.1,v1-latest= 1.0.1-client,-server,-cli)latest= 1.0.1,v1-latest= 1.0.1@<2.0.0origin/mainrootpackage.jsonengines.node >=22.7.5,publish-allstill has no--tagv1/mainrootpackage.jsonpublish-allpinned--tag v1-latestv2/mainrootpackage.jsonengines.node >=22.19.0v1/mainbranch1.0.1at tip, ruleset-protected, CI greenv1/main - full protection(branch) +v1 release tags(tag,refs/tags/1.*, deletion+update) — both activev1/main)push: branches: [main, v1/main]+ barepull_request:+release: [published]main)push: branches: [main]+pull_request:+release: [published]push:(all branches) +release: [published]; publish job asserts release tag == root version, runspack:verify, thennpm publish --access public --provenanceOriginal ground truth, as verified 2026-07-27 (the state this plan was written against)
@modelcontextprotocol/inspectorlatest= 1.0.0; no other dist-tagsorigin/mainrootpackage.json1.0.0at branch tip,engines.node >=22.7.5v2/mainrootpackage.jsonengines.node >=22.19.0publish-all=npm publish --workspaces --access public && npm publish --access public— no--taginspector-client,inspector-server,inspector-cli, all atlatest= 1.0.0push: branches: [main]+release: [published]mainbranch protection~DEFAULT_BRANCH— sov1/maininherited nothingBoth branches' workflows live at
.github/workflows/main.yml, and the release's target commit selects which one runs — that's the mechanism the whole plan leans on.Proven since: trusted publishing works from a non-default branch (the 1.0.1 publish from
v1/main, §4). npm matched on repo + workflow filename +releaseenvironment, with no branch or ref pinning — so theworkflow_dispatchfallback below is not needed.The plan as proposed
main→v1/mainso the v1 line continues to exist and can conceivably take security fixes.v1/main, merge a PR adding a prominent deprecated notice (README + runtime banner) plus the CI-trigger fix (§2), then publish 1.0.1 fromv1/main— which doubles as the trusted-publishing rehearsal (§4). Apply the npm-side deprecation handles (§3) after it's live.mainthat replaces the entire tree with v2.v2) with a note that v1 is deprecated and takes only required security fixes.v1/main, avoiding a future emergency branch-swap to ship a v1 security fix.Opinion — extra steps, and four things that will bite
§1–§4 are blockers or near-blockers for step 7 specifically; §5 onward are sequencing and hygiene.
🔴 1. A v1 security release will silently steal the
latesttagThis is the most important finding, and it makes step 7 mandatory, not optional.
publish-allhas no--tag, so npm assignslatestto whatever it publishes. Once 2.0.0 is out, publishing a 1.0.2 security fix fromv1/mainwould movelatestback to 1.0.2 — everynpx @modelcontextprotocol/inspectorin the world silently reverts to deprecated v1.Fixes, all needed:
v1/main'spublish-allto publish under--tag v1-latest(both the root and the three workspace packages).v1— npm rejects any dist-tag that parses as a SemVer range, andv1parses as1.x(npm error Tag name must not be a valid SemVer range: v1). Usingv1does not publish to the wrong tag; it fails the publish outright, at release time. Corrected in fix: v1 dist-tag must bev1-latest— npm rejectsv1#1829.latestlands correctly: publish 1.0.1 first (whilelatestis still the v1 line), then 2.0.0. If 1.0.1 slips to after 2.0.0, it must go out as--tag v1-latest.npm dist-tag add @modelcontextprotocol/inspector@1.0.1 v1-latest, sonpm i @modelcontextprotocol/inspector@v1-latestkeeps working and the deprecation message has somewhere to point. Applied to all four names, not just the root — the sub-packages'latestis equally stranded, so@v1-latestshould resolve consistently whichever name someone installed.🔴 2.
v1/maingets no CI at all, so step 7 is half-donev1's workflow triggers on
push: branches: [main]. The momentv1/mainexists, pushes to it match nothing — no build, no tests, no gate on the branch we're reserving for security fixes. Step 7 must also addv1/mainto that trigger (and to thepull_requestgate), otherwise a security fix would be published from an unvalidated branch.Whether v1 can be published from
v1/mainat all under trusted publishing is a separate and more serious question — see §4.🔴 3. The three v1 sub-packages need deprecating too
v1 publishes
inspector-client,inspector-server, andinspector-clialongside the root package. v2 publishes only the root package as a single tarball — so those three become permanently orphaned at 1.0.0 with a livelatesttag. Step 2's npm deprecation must cover all four names, or people keep installing sub-packages that will never be updated again.On the npm deprecation mechanics: use a version range, not the whole package —
npm deprecate pkg@"*"would warn on v2 installs too. For the three sub-packages the range is<2.0.0as well.<=1.0.0, written when 1.0.0 was their newest version — phase 2 published 1.0.1 to all three, so<=1.0.0would leave the versionlatestresolves to (the one everyone installs) un-deprecated.<2.0.0is safe for them since no 2.x will ever exist: v2 ships only the root package. Note deprecation is retroactive-but-mutable — it can be lifted with an empty message, so it's the one reversible step here.🟡 4. Can v1 publish from
v1/mainat all, given trusted publishing? — probably yes, but prove itThe concern raised: we use npm trusted publishing (OIDC), and the belief is that a publish must come from
main.ymlon the default branch. If that were true, step 7 would be impossible as written and a v1 security fix really would require swappingv1/mainintomain— the exact scenario step 7 exists to prevent.Per npm's documentation, the trusted publisher config does not pin a branch or ref. The fields are:
modelcontextprotocolinspectormain.ymlreleasenpm publishThere is no branch or ref field — authorization is repo + workflow filename (+ environment), and npm's own documented example is triggered from a tag push rather than a branch. So a release cut from a
v1/maincommit, runningv1/main's.github/workflows/main.ymlunderenvironment: release, should satisfy the same trusted publisher, because both branches use the identical workflow filenamemain.yml. That coincidence is what makes step 7 viable at all.Two consequences worth pulling out:
v1/main(plan step 2) rather than burning a throwaway1.0.1-rc.0. Same evidence, one fewer publish; see the rehearsal notes below.v1/main'smain.ymlis already authorized to publish the root package name — including tolatest. Anyone who can push tov1/maincan publish the v2 package. That's a concrete argument for protectingv1/mainwith the same rules asmain(§10), not leaving it as an unguarded parking branch.Fallback if the rehearsal fails — a dispatchable publish job on the default branch. This satisfies the "must run from
main.ymlon the default branch" constraint literally, and is worth considering even if the rehearsal passes, since it centralizes the release button on one branch:…with the job checking out
inputs.ref, building it, and runningnpm publish --tag ${{ inputs.npm_tag }}. The OIDC identity then comes frommain.ymlonmain— unambiguously matching the trusted publisher — while the published code comes fromv1/main.Keep the publish job inline in
main.yml: npm validates the calling workflow's filename, so factoring it out into a reusableworkflow_callworkflow breaks OIDC (and fails looking like a config mismatch).One caveat, only if we take this fallback route:
--provenancebuilds its attestation from the running workflow's OIDC claims (which referencemain), while the packed tree came from a different ref. Confirm npm accepts that rather than rejecting it as a source mismatch. If it doesn't, decide consciously whether a v1 security patch ships without provenance (acceptable) or via the branch-swap of last resort (much worse) — and write that decision down. This does not apply to the 1.0.1 rehearsal: there the workflow runs from the same ref it publishes.Rehearsing via the real 1.0.1 publish
Verified against
origin/main's workflow: v1 already uses OIDC (id-token: write,environment: release, noNPM_TOKEN) withNPM_CONFIG_PROVENANCE: "true", andpublishisif: github.event_name == 'release'withneeds: build—buildhas no branch condition, so a release cut from av1/maincommit runs the whole workflow and reaches publish. Five things to get right:Settings → Environments → release— verified 2026-07-27: Deployment branches and tags is "No restriction". So a1.0.1tag onv1/mainreaches the publish job; this is not a blocker. (If it had been restricted to selected refs, the job would have been blocked before npm was ever contacted, failing in a way that looks nothing like an OIDC rejection.) If required reviewers are configured, the run pauses for approval rather than failing — we have someone with approval rights, so that's a pause, not a risk. Note the flip side: with no restriction,v1/main'smain.ymlcan publish the root package name tolatestfrom the moment the branch exists, which is why §10's branch protection onv1/mainis a requirement rather than hygiene.--tag v1-latestyet. 1.0.1 must takelatest, which is the whole point of shipping the deprecation notice tonpxusers. Commit the--tag v1-latestchange topublish-allimmediately after the release, as its own commit — that gap is exactly where a forgotten flag later stealslatestback from 2.0.0 (§1).package.jsons to 1.0.1, or thecheck-versionstep fails the build.publish-allisnpm publish --workspaces && npm publish. If the workspaces succeed and the root fails, 1.0.1 lands for the three sub-packages with the root still at 1.0.0. Recoverable (bump to 1.0.2, go again), not clean.--tag v1-latestpublish path, and a publish in the post-2.0.0 world wherelatestis genuinely at risk. The first real v1 security fix is still the first true exercise of that.Also confirm npm ≥ 11.5.1 is in use (v1's workflow already has a step for this, since Node 22 bundles npm 10.x).
Sources: npm trusted publishers · npm trusted publishing GA changelog
5. How to actually do the tree replacement (step 3)
mainandv2/mainhave diverged enormously; a plaingit mergeis an unresolvable conflict field. I'd recommend a true merge that takes v2's tree wholesale, not a reset:This preserves
main's history (no force-push to the default branch, PR review trail intact) and makesv2/maina merge parent — somainis a true superset andv2/maincan afterwards be fast-forwarded tomainor retired cleanly. Agit reset --hard v2/main+ force-push would look simpler but discards main's history and requires disabling branch protection on the default branch. Verify after merge:git diff main v2/mainis empty.6. Re-validating the publishing pipeline before 2.0.0 (step 4)
The strongest available confidence check, and it's already built:
npm run pack:verifybuilds, packs, installs the real tarball into a clean throwaway consumer, and drives the installed bin (--help, a real--cli tools/listover stdio, a prod--webboot from the shippeddist). The publish job already runs it as a gate.Beyond that, I'd add one thing the repo can't self-test: publish a
2.0.0-rc.1under--tag nextto the real registry and install it vianpx @modelcontextprotocol/inspector@nexton a clean machine. That is the only way to validate the parts that only exist against live npm — provenance/OIDC minting, thefilesallowlist as npm actually packs it, the postinstall cascade's early-exit under a real dependency install, and the Docker job. A wasted prerelease version number is cheap next to a broken 2.0.0.Also confirm before cutting: the release tag is
v2.0.0or2.0.0(the assert tolerates the leadingv) targeting the post-mergemaincommit, and the Dockerlatesttag moves to 2.0.0 while a1-pinned tag is preserved for v1 users.7. Rollback plan (there needs to be one in writing)
npm unpublish is effectively unavailable (72h window, and disallowed once others depend on it). So the rollback for a broken 2.0.0 is dist-tag surgery, not unpublish:
npm dist-tag add @modelcontextprotocol/inspector@1.0.1 latest, thennpm deprecatethe bad 2.0.0, then ship 2.0.1. Write this down before publishing, and make sure whoever cuts the release has the npm rights to do it.8. Bulk-closing v1 issues and PRs (step 5) — do it auditably
closed-v1-deprecated) rather than only a comment, so the set stays findable later. A comment alone is very hard to query.v2" without a review pass: some open v1 issues may describe real bugs that still exist in v2, or security items we actually want. Triage into port to v2 (relabelv2, add to board Add tab and approval flow for server -> client sampling #28) vs. close as deprecated before running the script.9. Restricting PRs to write-access contributors (step 6)
Settings → Features → Pull requests → "Collaborators only". The PR tab stays visible and anyone can read and comment, but only users with write access can open new ones. Issues are unaffected — exactly the model step 6 describes. One click, reversible.
Before relying on it, confirm what "collaborator" resolves to here — it means write/maintain/admin on the repo, which may include org-wide base write, not just the maintainer team:
gh api repos/modelcontextprotocol/inspector/collaborators --jq '.[] | select(.permissions.push) | .login'The setting gives the contributor no explanation — they just find no "Create pull request" button. So
CONTRIBUTORS.md(issue #1517) and issue templates still need to land, and land first.Docs · changelog
Also decide the security-report path explicitly while we're here:
SECURITY.mdshould carry a supported-versions table (v2 = supported, v1 = security fixes only, everything below 1.0.0 = unsupported) and private reporting should be enabled.10. Things easy to forget in the swap
v1/main(protect it, or the "it can take security fixes" story is fiction), and tag protection for1.xtags.Closes #Nstarts working. Per AGENTS.md, closing keywords only auto-close on the default branch — v2 PRs targetingv2/mainnever auto-closed. After the swap, PRs intomaindo auto-close their issues. Update AGENTS.md, and stop the manual close-and-move-to-Done ritual.v2/mainas the base branch and describemainas legacy. All of that inverts. The board conventions section needs a full pass.v2label is noise — decide whether to retire it or invert to av1label. Archive project get tools working #11; Add tab and approval flow for server -> client sampling #28 becomes the only board.blob/main/...will silently resolve to the v2 tree. Anything that must keep pointing at v1 should be re-pinned to the1.0.1tag orv1/main.--configsemantics are genuinely different in v2 (read-only session file, with--catalogas the writable one). Without this, the deprecation notice sends people somewhere confusing. Pairs with the docs-site rewrite in docs: new modelcontextprotocol.io Inspector documentation for v2 (legacy vs. modern era, more screenshots) #1803.v2/mainduring the swap, and pick a low-traffic window. Createv1/main→ deprecation notice + CI-trigger fix on it → publish 1.0.1 fromv1/main(the rehearsal) → add--tag v1-latesttopublish-all→ merge v2 tree tomain→ verify CI green onmain→ publish 2.0.0 → then the cleanup steps (5, 6, 7), which are all reversible.Deliverable
An agreed, written go-live runbook (ordered, with the irreversible steps flagged and a rollback line for each), broken into per-phase tracked issues on board #28.