Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 105 additions & 50 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,66 +4,121 @@

1. Change the dependency version in `pyproject.toml`. The root `mcp` project's
runtime dependencies are dynamic and live under
`[tool.hatch.metadata.hooks.uv-dynamic-versioning].dependencies`.
2. Upgrade lock with `uv lock --resolution lowest-direct`

Check notice on line 8 in RELEASE.md

View check run for this annotation

Claude / Claude Code Review

Stale dependency-bump step: uv lock --resolution lowest-direct would mass-downgrade the committed lockfile

Pre-existing issue (this line is unchanged by the PR): step 2 of "Bumping Dependencies" says "Upgrade lock with `uv lock --resolution lowest-direct`", but that command doesn't upgrade anything — it re-resolves every direct dependency down to its declared floor, mass-downgrading the committed `uv.lock` (pydantic 2.12.5 → 2.12.0, typer 0.17.4 → 0.16.0, …) and persisting `resolution-mode = "lowest-direct"` so later plain `uv lock` runs silently stay in that mode. Since this PR's purpose is making R
Comment on lines 7 to 8

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.

🟣 Pre-existing issue (this line is unchanged by the PR): step 2 of "Bumping Dependencies" says "Upgrade lock with uv lock --resolution lowest-direct", but that command doesn't upgrade anything — it re-resolves every direct dependency down to its declared floor, mass-downgrading the committed uv.lock (pydantic 2.12.5 → 2.12.0, typer 0.17.4 → 0.16.0, …) and persisting resolution-mode = "lowest-direct" so later plain uv lock runs silently stay in that mode. Since this PR's purpose is making RELEASE.md the accurate operative runbook, consider fixing the one carried-over stale line too: uv lock or uv lock --upgrade-package <package> (the procedure AGENTS.md already documents).

Extended reasoning...

The bug. The "Bumping Dependencies" section (RELEASE.md lines 7-8) survives this rewrite unchanged, and its step 2 — "Upgrade lock with uv lock --resolution lowest-direct" — is wrong in a way that would actively damage the repo if followed. --resolution lowest-direct tells uv to resolve every direct dependency in the workspace to the lowest version satisfying its specifier, i.e. its declared floor. Running it doesn't upgrade the lock after a floor bump; it rewrites uv.lock as a mass downgrade of everything else.

Evidence the committed lock was never produced this way. The current uv.lock is a standard highest-resolution lock: (1) it contains no [options] section / resolution-mode entry, which uv writes whenever a non-default --resolution is used; (2) locked versions sit above their floors — pydantic 2.12.5 vs >=2.12.0 (pyproject.toml:129), typer 0.17.4 vs >=0.16.0 (pyproject.toml:29), anyio 4.10.0. A lowest-direct resolution would pin all of these at their floors. So the documented command has never actually been used to produce the committed lock, and running it would produce a huge unintended diff.

Step-by-step proof of the failure. (1) A maintainer bumps one dependency floor per step 1, e.g. raises httpx to >=0.28. (2) Following step 2 verbatim, they run uv lock --resolution lowest-direct. (3) uv re-resolves the whole workspace in lowest-direct mode: typer drops 0.17.4 → 0.16.0, pydantic 2.12.5 → 2.12.0, and every other direct dependency falls to its floor. (4) uv also records resolution-mode = "lowest-direct" in the lockfile's [options], so every subsequent plain uv lock silently stays in lowest-direct mode. (5) Once committed, CI's "locked" matrix leg (which installs --frozen from uv.lock) and every developer environment run against floor versions — while the dedicated lowest-direct leg (.github/workflows/shared.yml:64-68, which applies --upgrade --resolution lowest-direct at install time precisely so the committed lock stays highest-resolution) becomes redundant. The repo loses its highest-resolution test coverage entirely.

Why nothing else prevents it. RELEASE.md is the operative runbook a maintainer follows verbatim, and the instruction is internally self-contradictory only on close reading (a command labeled "Upgrade lock" performs a downgrade). It also directly contradicts the repo's own AGENTS.md, which documents the correct procedure: "Upgrading: uv lock --upgrade-package <package>" and "Always pass --frozen so uv doesn't rewrite uv.lock as a side effect."

Why flag it on this PR despite being untouched. All four verifiers confirmed the line predates this PR (it dates to when RELEASE.md was created) and sits as unchanged context above the rewrite — hence pre-existing, not blocking. But the PR's stated purpose is making RELEASE.md accurate for the person actually cutting releases, and this is the one remaining section carried over unreviewed. Fixing it is a one-line change.

Fix. Change step 2 to "Upgrade lock with uv lock --upgrade-package <package>" (matching AGENTS.md), or simply uv lock if the intent is a plain re-lock after editing the floor.


## Major or Minor Release

Stable releases are cut from the `v1.x` branch. Create a GitHub release via UI
with the tag being `vX.Y.Z` where `X.Y.Z` is the version and the release title
being the same, and **set the tag's target to the `v1.x` branch** — the UI
defaults to `main`, which is the v2 rework, and a v1 tag created there would
publish the v2 codebase as a stable release. Then ask someone to review the
release.

The package version will be set automatically from the tag.

## v2 Pre-releases

v2 pre-releases are cut from `main` with a PEP 440 pre-release tag: `v2.0.0aN`
for alphas, later `bN`/`rcN` for betas and release candidates.

A release publishes two distributions, `mcp` and `mcp-types`, at the same
version, and the `mcp` wheel exact-pins `mcp-types`. Before the first release
that includes both, the `mcp-types` PyPI project must be given the same
trusted publisher as `mcp` (this repository, workflow `publish-pypi.yml`,
environment `release`) and the same owners — without it the `mcp-types`
upload is rejected. If only some of the files upload, fix the cause and re-run
the publish job — `skip-existing` makes it skip whatever already landed. The
`Development Status` classifier in both `pyproject.toml` files is permanently
`5 - Production/Stable`; it is not bumped as part of any release.

1. Update the pre-release version examples in `README.md` and the docs
(grep the outgoing version — the pins live in the README Installation
section, `docs/index.md`, `docs/get-started/installation.md`, and `docs/get-started/real-host.md`) so the tagged
commit — and therefore the README PyPI publishes — names the version
being released. When entering a new phase (alpha → beta → rc), update
the banner wording too.
2. Check the full test matrix is green on the release commit. The publish
workflow re-runs the checks and blocks publishing until they pass, so a
red leg there means re-running the failed jobs on the Publishing run.
3. Create the release as a pre-release, passing the exact commit verified in
step 2 as `--target` (otherwise the tag is created from whatever `main`'s
HEAD is by then). The tagged commit determines everything about the
## Release lines

Two branches ship, and the package version comes from the git tag
(`uv-dynamic-versioning`). Publishing a GitHub release runs `publish-pypi.yml`
**from the tagged commit**, so the workflow that fires is the tagged branch's

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: A v1.x release will use the default-branch release workflow, not a workflow definition from v1.x; current publish-pypi.yml therefore attempts to build mcp-types too. Document/configure a default-branch workflow that branches on the release tag (or use a supported separate trigger) before directing maintainers to cut v1 releases this way.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At RELEASE.md, line 14:

<comment>A `v1.x` release will use the default-branch release workflow, not a workflow definition from `v1.x`; current `publish-pypi.yml` therefore attempts to build `mcp-types` too. Document/configure a default-branch workflow that branches on the release tag (or use a supported separate trigger) before directing maintainers to cut v1 releases this way.</comment>

<file context>
@@ -7,63 +7,118 @@
+
+Two branches ship, and the package version comes from the git tag
+(`uv-dynamic-versioning`). Publishing a GitHub release runs `publish-pypi.yml`
+**from the tagged commit**, so the workflow that fires is the tagged branch's
+own: a `main` tag builds and publishes two distributions (`mcp` and
+`mcp-types`, lock-stepped via `Requires-Dist: mcp-types=={{ version }}`), and a
</file context>

own: a `main` tag builds and publishes two distributions (`mcp` and
`mcp-types`, lock-stepped via `Requires-Dist: mcp-types=={{ version }}`), and a
`v1.x` tag builds and publishes `mcp` only.

| Line | Branch | Tag | GitHub release flags |
| ---------------------------- | ------ | ------------------------- | ------------------------------------- |
| Current stable | `main` | `v2.X.Y` | not a pre-release; becomes **Latest** |
| Maintenance (previous major) | `v1.x` | `v1.28.Z` | not a pre-release; **not** Latest |
| Pre-releases | `main` | `v2.X.YaN` / `bN` / `rcN` | **Pre-release** ticked, never Latest |

The `Development Status` classifier in both `pyproject.toml` files is
permanently `5 - Production/Stable`; it is not bumped as part of any release.
The `mcp-types` PyPI project carries the same trusted publisher as `mcp` (this
repository, workflow `publish-pypi.yml`, environment `release`). If only some
of the four files upload, fix the cause and re-run the publish job —
`skip-existing` makes it skip whatever already landed.
Comment on lines +27 to +30

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.

🟡 The partial-upload recovery advice in the shared "Release lines" section ("re-run the publish job — skip-existing makes it skip whatever already landed") only holds for tags cut from main: the v1.x branch's own publish-pypi.yml invokes pypa/gh-action-pypi-publish without skip-existing: true, so a re-run after a partial v1.28.Z upload re-attempts the already-landed file and fails with PyPI's 400 "File already exists". Consider scoping the note to main-line releases (or adding a v1.x caveat), or backporting skip-existing: true to the v1.x workflow.

Extended reasoning...

The issue. The "Release lines" section now documents partial-upload recovery for both shipping lines: "If only some of the four files upload, fix the cause and re-run the publish job — skip-existing makes it skip whatever already landed." This claim is only true for tags cut from main. The doc itself establishes (correctly) that publishing runs publish-pypi.yml from the tagged commit, so a v1.28.Z tag runs the v1.x branch's own workflow — and that workflow invokes pypa/gh-action-pypi-publish@release/v1 with no inputs at all. The action's skip-existing default is false. Only main's workflow sets it (.github/workflows/publish-pypi.yml lines 62–64, with a comment explaining exactly this re-run scenario).\n\nStep-by-step failure scenario. (1) A v1.28.Z maintenance release partially uploads — say the wheel lands on PyPI but the sdist upload dies on a transient network error. (2) The release engineer consults this doc's recovery instruction and re-runs the publish job. (3) The re-run executes the v1.x workflow from the tagged commit; without skip-existing, twine re-attempts the already-uploaded wheel. (4) PyPI rejects it with 400 File already exists and the job fails again — the documented recovery procedure dead-ends at exactly the moment (a broken partial release) when the engineer most needs the doc to be right.\n\nWhy nothing else in the doc prevents this. The "Maintenance release from v1.x" section says the process is "the same way with two differences" (tag target and --latest=false) — neither covers this workflow divergence, so a reader has no warning that the recovery note doesn't apply to their line.\n\nWhy this PR introduced it. The old RELEASE.md scoped the skip-existing note to the "v2 Pre-releases" section, where it described main-only tags and was accurate. This PR moved the sentence into the shared "Release lines" section that describes both lines, over-generalizing it to v1.x where it is false.\n\nMitigating factors (why this is a nit, not blocking). The sentence is phrased "if only some of the four files upload", which literally describes only the main-line dual publish (mcp + mcp-types × wheel + sdist; v1.x ships two files) — weak implicit scoping, but scoping nonetheless. The failure only materializes in an already-rare contingency (partial upload on the maintenance line), and the consequence is a noisy failed re-run that prompts investigation — PyPI state is unchanged, nothing user-facing breaks, and the workaround (manual twine upload --skip-existing, or backporting the option) is straightforward.\n\nFix. Either add a one-line caveat that the v1.x workflow lacks skip-existing (so a partial v1.x upload needs a manual re-upload or a workflow backport first), or backport skip-existing: true to v1.x's publish-pypi.yml so the advice holds on both lines.


## Stable release from `main` (`v2.X.Y`)

The stable line's README and docs carry no version pin (`pip install "mcp[cli]"`
installs the newest stable release), so a stable release needs no pin-flip
commit. `README.md` at the tagged commit is the PyPI long description, so any
README fix has to merge before the tag.
Comment on lines +34 to +37

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.

🔴 The stable-release section opens with "The stable line's README and docs carry no version pin ... so a stable release needs no pin-flip commit", but at the moment this section is first used — cutting v2.0.0 — the README and docs still pin mcp[cli]==2.0.0b1 and carry the pre-release banner, so following it verbatim publishes an immutable PyPI long description for v2.0.0 that instructs users to install a beta. Add a note that the first stable release requires the pin-drop/banner-removal commit before tagging (or cross-reference the phase-transition instruction in step 1 of the Pre-releases section).

Extended reasoning...

The bug. The new "Stable release from main (v2.X.Y)" section is premised on a steady state that does not yet exist. Its opening sentence — "The stable line's README and docs carry no version pin (pip install \"mcp[cli]\" installs the newest stable release), so a stable release needs no pin-flip commit" — is false at the one moment the section will first be executed: the v2.0.0 cut, which the PR description names as this doc's primary audience ("The person cutting v2.0.0 ... reads this file").

Current state of the tree. README.md:44-47 pins mcp[cli]==2.0.0b1 and carries the banner "The pin matters while v2 is in pre-release"; the same ==2.0.0b1 pin appears in docs/index.md, docs/get-started/installation.md, and docs/get-started/real-host.md ("The pin is mandatory while v2 is in beta"). So contrary to the section's premise, a pin-drop/banner-removal commit is required before tagging v2.0.0.

Why the doc doesn't otherwise prevent the mistake. The only instruction to drop the pins lives in step 1 of the separate "Pre-releases from main" section ("When entering a new phase (alpha → beta → rc → stable), update the banner wording too; the stable phase drops the pins"). A release engineer performing a stable release has no procedural reason to read the pre-release section — and the stable section affirmatively tells them the opposite ("needs no pin-flip commit"), actively steering them away from the required step.

Impact. The doc itself explains why this matters: "README.md at the tagged commit is the PyPI long description." Following the stable section verbatim tags v2.0.0 on a commit whose README tells users to pip install \"mcp[cli]==2.0.0b1\" and describes v2 as pre-release. Under the doc's own rules the published long description for that version is immutable — never delete a release from PyPI, versions cannot be reused — so the only remedy is cutting v2.0.1. That is a concrete, effectively irreversible publication mistake for the flagship stable release, caused by the very document written to prevent it.

Step-by-step proof. (1) The release engineer preparing v2.0.0 opens RELEASE.md and jumps to "Stable release from main (v2.X.Y)" — the section named for their task. (2) They read "a stable release needs no pin-flip commit" and therefore make no README/docs change. (3) They follow steps 1–3: verify green, freeze main, run gh release create v2.0.0 --title v2.0.0 --target <sha> .... (4) publish-pypi.yml runs from the tagged commit and publishes mcp 2.0.0 with the tagged README.md as its long description. (5) The PyPI page for stable v2.0.0 — now what pip install mcp resolves to — instructs pip install \"mcp[cli]==2.0.0b1\" and says "Do not use v2 in production" / "The pin matters while v2 is in pre-release". (6) Per the yank rules in step 5 of the same section, the fix requires releasing v2.0.1.

Fix. One sentence in the stable section: note that the first stable release (v2.0.0) requires the pin-drop/banner-removal commit to land before the tag (the phase-transition step described in the Pre-releases section, step 1) — or rephrase the opening so the "no pin-flip" claim is explicitly conditional on the pins having already been dropped at the pre-release → stable transition.

Severity. All three verifiers confirmed the finding; two rated it normal, one nit (on the grounds that the information exists elsewhere in the same file and the window is a single release). Normal is warranted here: the single release in the window is exactly the release this PR was written to instruct, the section doesn't merely omit the step but asserts its negation, and the failure it invites is an immutable published artifact fixable only by cutting another version — while the fix is one sentence.


1. Check the full test matrix is green on the release commit. The publish
workflow re-runs the same checks and blocks publishing until they pass, so a
red leg there means re-running the failed jobs on the Publishing run — but
verify green before creating the release rather than discovering red after
the tag exists.
2. Freeze `main` from that commit until the tag exists: the release is created
with an explicit `--target`, and nothing else should land in between.
3. Create the release NOT as a pre-release, passing the verified commit as
`--target` (otherwise the tag is created from whatever `main`'s HEAD is by
then). It becomes GitHub "Latest", and PyPI's default `pip install mcp`
version moves to it. The tagged commit determines everything about the
release — the workflows that run and the package metadata (readme,
classifiers) that gets published — so it must contain the current release
tooling, not just pass tests. `--target` is ignored if the tag already
exists: when re-creating a release, delete the old tag first and
double-check where the new tag points. The pre-release flag keeps GitHub's
"Latest" badge and `/releases/latest` pointing at the stable v1.x line:
double-check where the new tag points.

```shell
gh release create v2.X.Y --title v2.X.Y --target <commit-sha> --notes-file <notes.md>
```

4. Curate the release notes above the auto-generated `## What's Changed` list:
the highlights, anything known-incomplete, and links to the docs and
migration guide. Use absolute URLs (relative links don't resolve in GitHub
release bodies), and set the generated list's **Previous tag** to the
previous release on this line by hand — the auto-picked baseline is the
newest tag, which may sit on the other line.
Comment on lines +60 to +65

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.

🟡 Step 3's command (gh release create ... --notes-file <notes.md>) never auto-generates anything, yet step 4 tells the reader to curate above "the auto-generated ## What's Changed list" and set its Previous tag by hand — artifacts that only exist via --generate-notes/--notes-start-tag or the web UI's Generate-release-notes button, neither of which the doc mentions; the v1.x section (command plus the "When generating notes..." paragraph) repeats the same mismatch. A one-line bridge — either add --generate-notes --notes-start-tag <previous-tag-on-this-line> to the commands, or state that notes.md is drafted from the UI generator with Previous tag set there — closes the gap.

Extended reasoning...

The mismatch. Step 3 prescribes an exact command: gh release create v2.X.Y --title v2.X.Y --target <commit-sha> --notes-file <notes.md>. With only --notes-file, gh uses the file verbatim as the release body and generates nothing. Step 4 then instructs the reader to "Curate the release notes above the auto-generated ## What's Changed list" and to "set the generated list's Previous tag to the previous release on this line by hand." But on the documented command path there is no auto-generated list and no Previous tag to set: gh only produces the ## What's Changed list with --generate-notes (baseline chosen via --notes-start-tag), and Previous tag is the label of a dropdown in the web UI's Generate-release-notes form — a flow the doc never directs the reader to. The maintenance section repeats the mismatch: the v1.28.Z command block again uses only --notes-file, and the following paragraph says "When generating notes, set Previous tag to the previous v1.* release by hand" without ever saying where that generation happens.\n\nStep-by-step walk-through. (1) A release manager follows step 3 literally: gh release create v2.0.1 --title v2.0.1 --target abc123 --notes-file notes.md. (2) The release publishes with exactly the contents of notes.md as its body — no ## What's Changed section appears. (3) They reach step 4: "set the generated list's Previous tag ... by hand." There is no generated list in the body they just published, and gh release create's flag set has no "Previous tag" option — the closest CLI control is --notes-start-tag, which the doc never names and which only matters alongside --generate-notes. (4) The instruction cannot be executed from the text alone; the reader must independently discover either the flag pair or the UI's Generate-release-notes button.\n\nWhy this matters here specifically. The Previous-tag correction is the doc's own stated safeguard against a real failure mode it identifies: "the auto-picked baseline is the newest tag, which may sit on the other line." With two release lines shipping interleaved tags from main and v1.x, a naively generated notes list for v1.28.Z would diff against a v2.* tag (or vice versa) and produce a nonsense changelog. The one step guarding against that is the one step the runbook's command path never makes reachable — the gap lands exactly where the doc says precision matters.\n\nAddressing the refutation. One verifier argued the doc is coherent read as: create via CLI (step 3), then edit the release in the web UI and click Generate release notes (step 4), where the Previous tag dropdown genuinely lives — or prepare notes.md beforehand via the UI draft form or gh api repos/{owner}/{repo}/releases/generate-notes. That reading is plausible and is probably what the author intends (the bolded Previous tag is the literal UI control name). But the doc states none of it: it never mentions the UI generator, --generate-notes, --notes-start-tag, or the generate-notes API (verified by grep — none appear anywhere in the repo). A runbook whose command block and immediately following step describe two different, unbridged workflows is a real coherence defect, not a style preference. The refutation is right, however, that there is no dead end or irreversible outcome — release bodies are freely editable metadata — which is why this is a nit, not a blocker.\n\nFix. Either (a) extend both commands with --generate-notes --notes-start-tag <previous-tag-on-this-line> (with --notes-file, gh appends the generated list below the provided notes — exactly the "curate above the list" layout step 4 describes, and --notes-start-tag replaces the by-hand Previous-tag instruction with a deterministic flag), or (b) add one sentence stating that notes.md is drafted from the UI's Generate-release-notes button with Previous tag set to the previous release on the same line, then saved and passed to --notes-file. Apply the same fix to the v1.28.Z command and its notes paragraph.\n\nSeverity. Docs-only; nothing breaks on merge. An experienced release manager will bridge the gap themselves, and the worst realistic outcome is confusion or a cosmetically wrong notes baseline that can be edited after the fact. Worth a one-line fix in a PR whose entire purpose is a precise runbook, but not worth blocking merge: nit.

5. If a stable release turns out to be broken, yank it on PyPI and release the
fix as the next patch version. Never delete a release from PyPI — version
numbers cannot be reused. Yank `mcp` and `mcp-types` together (they are one
release), and set the yank reason and the GitHub release notes to point at
the replacement version, since yanking doesn't stop `==` pins from installing
the broken version.
Comment on lines +66 to +71

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.

🟡 The "ask someone to review the release" step now survives only in the lower-stakes v1.x maintenance section (line 93), while the new stable-from-main procedure — the one that takes GitHub "Latest" and moves PyPI's default pip install mcp, with mistakes fixable only by yank-plus-new-patch per step 5 — has no review step in any of its 5 steps. Consider adding the same sentence at the end of this section so the second-pair-of-eyes check covers the highest-stakes flow too.

Extended reasoning...

What happened. The pre-PR RELEASE.md ended its stable-release procedure ("Major or Minor Release") with "Then ask someone to review the release." After this rewrite, that sentence exists exactly once — at line 93, closing the Maintenance release from v1.x section. The new Stable release from main (v2.X.Y) section (lines 31–71) has no review step in any of its 5 steps.

Why this looks like an oversight, not a decision. One could argue the sentence simply stayed attached to the section it originally belonged to: the old stable procedure was the v1.x procedure, and its direct textual successor is the new maintenance section. That's mechanically true — but the review requirement was attached to the stable role, not to the v1.x branch as such: it closed the procedure for the release that takes GitHub "Latest" and PyPI's default install. After the flip, that role belongs to the v2-from-main cut, yet the safeguard didn't move with it. Two details in the new doc itself support the oversight reading: (1) the maintenance section frames itself as "create the release the same way with two differences" — and dropping the review step is not one of the listed differences, so the asymmetry isn't presented as intentional anywhere; (2) nothing in the PR description mentions removing the review requirement for stable releases.

Why it matters. The stable section's own step 5 explains why a pre-publish second pair of eyes is most valuable exactly here: never delete from PyPI, version numbers cannot be reused, and the only remedy for a broken stable release is yanking mcp and mcp-types together plus cutting the next patch. The failure modes the doc warns about — tagging the wrong commit (--target ignored when the tag exists), a stale README becoming the immutable PyPI long description, missing release tooling at the tagged commit — are all things a reviewer can catch before gh release create runs. The automated safeguards the new section adds (verify green, freeze main, explicit --target, publish workflow re-runs checks) all apply to both lines via the shared release environment, so none of them substitutes asymmetrically for the human review that now only the maintenance line gets.

Concrete walk-through. A maintainer cutting v2.1.0 reads only the stable section (it's named for their task). They follow steps 1–5: verify green, freeze main, run gh release create v2.1.0 --title v2.1.0 --target <sha> --notes-file notes.md, curate notes, done — no step tells them to have anyone look before or after publishing. Meanwhile a maintainer cutting v1.28.5 from the maintenance line is explicitly told "Then ask someone to review the release." The doc now requires more scrutiny for the release that can't even take "Latest" than for the one that moves pip install mcp for everyone.

Fix. Add "Then ask someone to review the release." (or an explicit review-before-publish step) at the end of the stable section, restoring parity with both the old doc's requirement and the maintenance section.

Severity. Nit: this is a docs-process omission — no code or automation is affected, and whether a v2 stable cut requires a second reviewer is ultimately a maintainer policy choice. It's a one-sentence fix that shouldn't block merge.


## Maintenance release from `v1.x` (`v1.28.Z`)

Land the `[v1.x]`-prefixed backport PRs (and any README banner update, which is
the README PyPI shows for that version), verify the branch tip green, then
create the release the same way with two differences:

- **The tag's target is the `v1.x` branch.** The UI and CLI default the target
to `main`, which is the v2 codebase — a v1 tag created there would publish v2
code as a v1 stable release.
- **It must not take "Latest" back from the 2.x line.** The UI ticks "Set as
the latest release" by default for the newest non-pre-release; untick it, or
pass `--latest=false`, and afterwards confirm `/releases/latest` still names
the newest v2 tag. If it slipped, `gh release edit v1.28.Z --latest=false`
fixes it — release metadata only, no re-cut.

Check warning on line 86 in RELEASE.md

View check run for this annotation

Claude / Claude Code Review

Maintenance post-release check fails whenever the newest v2 tag is a pre-release

The maintenance section's post-release check — "confirm `/releases/latest` still names the newest v2 tag" — is literally unsatisfiable whenever the newest v2 tag is a pre-release (e.g. `v2.1.0b1`), which is the normal cadence this doc documents: pre-releases are never Latest, so `/releases/latest` correctly names an older stable while the check reads as failed. One-word fix: "newest v2 tag" → "newest stable v2 release", matching the pre-release section's own wording.
Comment on lines +82 to +86

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.

🟡 The maintenance section's post-release check — "confirm /releases/latest still names the newest v2 tag" — is literally unsatisfiable whenever the newest v2 tag is a pre-release (e.g. v2.1.0b1), which is the normal cadence this doc documents: pre-releases are never Latest, so /releases/latest correctly names an older stable while the check reads as failed. One-word fix: "newest v2 tag" → "newest stable v2 release", matching the pre-release section's own wording.

Extended reasoning...

The issue. The Maintenance section (RELEASE.md lines 82–86) instructs, after cutting v1.28.Z with --latest=false: "afterwards confirm /releases/latest still names the newest v2 tag." Taken literally, this verification check fails in the doc's own steady state — and the doc itself explains why.

Why the literal check is unsatisfiable. Two other parts of this same PR establish that /releases/latest can never name a pre-release: the Release lines table says pre-releases are "Pre-release ticked, never Latest", and pre-release step 3 says "The pre-release flag keeps GitHub's 'Latest' badge and /releases/latest on the newest stable release." Meanwhile, the doc's documented cadence ("Pre-releases of the next version are cut from main") means the newest v2 tag is routinely a pre-release tag between stables. So the newest v2 tag and the tag /releases/latest names are, by design, often different tags.

Step-by-step proof. (1) v2.0.0 is released stable from main and takes Latest. (2) v2.1.0b1 is tagged as a pre-release, per the Pre-releases section — /releases/latest correctly stays on v2.0.0. (3) A release manager cuts v1.28.Z per the Maintenance section, passing --latest=false as instructed. (4) They run the prescribed confirmation: /releases/latest returns v2.0.0. (5) The newest v2 tag, however, is v2.1.0b1 — so the literal check "still names the newest v2 tag" is not satisfied, even though every release flag is correct.

Why the offered remedy makes it worse. The next sentence says "If it slipped, gh release edit v1.28.Z --latest=false fixes it." In the scenario above, nothing slipped — --latest=false was already passed and the edit changes nothing. Nor is there any command that could make the literal check pass: GitHub refuses to mark a pre-release as Latest. The reader is left with a verification step that reports failure and a remedy that is a no-op.

Why this is an internal inconsistency, not a pedantic reading. The same document uses the precise wording where it matters: the pre-release section says the flag keeps /releases/latest "on the newest stable release", and step 4 of the stable section even warns that "the auto-picked baseline is the newest tag, which may sit on the other line" — the author clearly distinguishes tags from Latest-eligible releases elsewhere. The Maintenance check is the one place the distinction was dropped.

Impact and fix. Impact is limited to release-time confusion: the bolded rule ("must not take Latest back from the 2.x line") makes the intent recoverable — a reader seeing any stable v2 release at /releases/latest would likely move on. But a runbook verification step whose literal success condition is false in the documented normal state is a genuine wording defect. One-word fix: change "the newest v2 tag" to "the newest stable v2 release", matching the pre-release section's own phrasing.


```shell
gh release create v1.28.Z --title v1.28.Z --target v1.x --latest=false --notes-file <notes.md>
```
Comment on lines +79 to +90

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.

🟡 The maintenance-release command passes the branch name (--target v1.x), so gh resolves it to whatever the v1.x HEAD is when the release is created — not the commit verified green, reintroducing the moving-HEAD race the stable and pre-release sections explicitly guard against with --target <commit-sha>. Since this section's own first step guarantees landing traffic on v1.x right before the release, consider using the verified v1.x tip SHA as --target (or adding the same freeze caveat the stable section has).

Extended reasoning...

The issue. The maintenance section's command is gh release create v1.28.Z --title v1.28.Z --target v1.x --latest=false --notes-file <notes.md>. gh release create --target accepts a branch name and resolves it to that branch's HEAD at the moment the release is created (it is sent as target_commitish). So the tag can land on a commit other than the one the release manager just verified green.

Why this contradicts the doc's own rules. The stable section (step 3) requires "passing the verified commit as --target (otherwise the tag is created from whatever main's HEAD is by then)" and adds an explicit freeze step ("nothing else should land in between"). The pre-release section likewise requires the verified commit as --target. The maintenance section is the only one of the three that tags a moving ref, and it has no freeze instruction — despite framing itself as "the same way with two differences" (the branch-name target is actually a third, undisclosed difference from the stable command's <commit-sha>).

The trigger is built into the section itself. Its first instruction is "Land the [v1.x]-prefixed backport PRs ... verify the branch tip green, then create the release" — so by construction there is landing traffic on v1.x around release time, and drafting curated notes takes real time between the green verification and the gh release create call. If another backport merges in that window, v1.28.Z is tagged from a commit that was never verified.

Why the bolded rationale doesn't require the branch-name form. The stated reason for targeting v1.x is only to avoid the default target of main ("a v1 tag created there would publish v2 code as a v1 stable release"). Passing the verified v1.x tip SHA achieves that equally well — it is a commit on v1.x, so the tag still lands on the right line — while also pinning the verified commit.

Concrete walk-through. (1) Release manager lands two backports on v1.x and verifies the tip abc123 green. (2) They spend 30 minutes curating notes.md per step "When generating notes...". (3) Meanwhile a colleague merges another [v1.x] backport, moving HEAD to def456. (4) The manager runs the documented command; gh resolves v1.xdef456 and tags there. (5) publish-pypi.yml runs from def456 — an unverified commit not covered by the curated notes. Per the doc's own yank rules, if it's broken the only remedy is yanking and cutting v1.28.(Z+1); versions cannot be reused.

Why this is a nit, not blocking. The failure window is narrow in practice: v1.x is a low-traffic maintenance branch typically controlled by the same release manager, the branch-name target is carried over from the pre-PR RELEASE.md rather than newly introduced behavior, and the publish workflow re-runs the full check matrix from the tagged commit and blocks publishing until green — so a racing commit can only ship if it passes CI. The worst realistic outcome is a release containing an unintended-but-green backport not described by the curated notes, not broken code on PyPI.

Fix. Change the example to --target <commit-sha> with a note that the SHA must be the verified v1.x tip (preserving the wrong-branch protection while pinning the verified commit), or at minimum add the same freeze caveat the stable section carries.


When generating notes, set **Previous tag** to the previous `v1.*` release by
hand for the same reason as above. Then ask someone to review the release.

## Pre-releases from `main`

Pre-releases of the next version are cut from `main` with a PEP 440
pre-release tag: `aN` for alphas, later `bN`/`rcN` for betas and release
candidates. The PEP 440 suffix is what keeps `pip install mcp` on the stable
version — installers only select a pre-release when it is requested by exact
pin.

1. During a pre-release phase the README and docs pin the exact pre-release
version, so update those examples first (grep the outgoing version — the
Comment on lines +100 to +104

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.

🟡 The claim that installers only select a pre-release "when it is requested by exact pin" is over-narrow: pip also resolves pre-releases with --pre, and with any specifier that names a pre-release version (e.g. pip install 'mcp>=2.1.0b1'); uv behaves similarly via --prerelease allow. Consider rewording to "installers skip pre-releases unless explicitly requested — via --pre or a specifier naming a pre-release version."

Extended reasoning...

The issue. The new "Pre-releases from main" section (RELEASE.md:100-104) states: "The PEP 440 suffix is what keeps pip install mcp on the stable version — installers only select a pre-release when it is requested by exact pin." The word "only" makes this a false universal. Per pip's documented pre-release handling, a pre-release is selected in three cases, not one:

  1. An exact pin naming a pre-release (mcp==2.1.0b1) — the case the doc mentions.
  2. The --pre flag: pip install --pre mcp will install the newest version including pre-releases.
  3. Any version specifier that itself names a pre-release version, with any operator — e.g. pip install 'mcp>=2.1.0b1'. Per PEP 440 / the packaging library's specifier semantics, a specifier containing a pre-release segment implicitly enables pre-release candidates for that resolution.

uv behaves analogously: --prerelease allow (or UV_PRERELEASE=allow), and its default if-necessary-or-explicit mode admits pre-releases when a requirement explicitly names one.

Concrete walk-through. Suppose 2.0.0 (stable) and 2.1.0b1 (pre-release) are both on PyPI. pip install mcp → installs 2.0.0 (correct, and this is the clause the doc gets right). But pip install --pre mcp → installs 2.1.0b1 with no pin at all, and pip install 'mcp>=2.1.0b1' → also installs 2.1.0b1, again with no exact pin. So "only … exact pin" is contradicted by two documented, commonly-used mechanisms.

Why it matters (and why it's minor). This file exists to give the release manager a precise mental model of release mechanics — that's the stated purpose of the rewrite in this PR — so a factually wrong universal in freshly-added prose is worth fixing. That said, nothing in the actual release procedure depends on the erroneous clause: the operative claim (the PEP 440 suffix keeps plain pip install mcp on stable, so pre-releases never hijack the default install) is true, and every pin example in the doc uses ==. No release step goes wrong if this merges as-is.

Fix. One-line rewording, e.g.: "The PEP 440 suffix is what keeps pip install mcp on the stable version — installers skip pre-releases unless explicitly requested, via --pre (--prerelease allow for uv) or a specifier naming a pre-release version."

All three verifiers independently confirmed the factual claim against pip's documented behavior and agreed on nit severity; there were no refutations. Docs-only, non-blocking.

pins live in the README Installation section, `docs/index.md`,
`docs/get-started/installation.md`, and `docs/get-started/real-host.md`) so
the tagged commit — and therefore the README PyPI publishes — names the
version being released. When entering a new phase (alpha → beta → rc →
stable), update the banner wording too; the stable phase drops the pins.
2. Check the full test matrix is green on the release commit, as above.
3. Create the release as a pre-release, passing the verified commit as
`--target`. The pre-release flag keeps GitHub's "Latest" badge and
`/releases/latest` on the newest stable release:

```shell
gh release create v2.0.0aN --prerelease --title v2.0.0aN --target <commit-sha>
gh release create v2.X.YbN --prerelease --title v2.X.YbN --target <commit-sha>
```

4. Curate the release notes instead of relying on auto-generated ones: what
changed since the previous pre-release, what is known-incomplete, the
install line (`pip install mcp==2.0.0aN`), and a link to the migration
guide. Use the absolute URL
(`https://github.com/modelcontextprotocol/python-sdk/blob/main/docs/migration.md`)
because relative links don't resolve in GitHub release bodies.
4. Curate the release notes: what changed since the previous pre-release, what
is known-incomplete, the install line (`pip install mcp==2.X.YbN`), and a
link to the migration guide, with absolute URLs.
5. If a pre-release turns out to be broken, yank it on PyPI and cut the next
one. Never delete a release from PyPI — version numbers cannot be reused.
Yanking doesn't stop `==` pins from installing the broken version, so set
the yank reason (and edit the GitHub release notes) to point at the
one, pointing the yank reason and the GitHub release notes at the
replacement version.
Comment on lines 122 to 124

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.

🟡 The pre-release yank step (step 5) says only "yank it on PyPI", but pre-releases are cut from main and therefore publish both mcp and mcp-types — the same situation the stable section's step 5 handles with "Yank mcp and mcp-types together (they are one release)". Since PyPI yanking is per-project, following this step verbatim leaves the broken mcp-types pre-release live and un-annotated for standalone consumers; consider adding "yank both distributions, as above" (or equivalent) here.

Extended reasoning...

The inconsistency. This PR introduces the rule that mcp and mcp-types are yanked together — the PR description says it "records that mcp and mcp-types are yanked together", and the stable section's step 5 states it explicitly: "Yank mcp and mcp-types together (they are one release)". But the rationale applies identically to pre-releases: per this PR's own "Release lines" section, any main tag "builds and publishes two distributions (mcp and mcp-types, lock-stepped via Requires-Dist: mcp-types=={{ version }})", and pre-releases are cut from main. Yet the pre-release section's step 5 says only "yank it on PyPI and cut the next one" — no mention of mcp-types, and no "as above" cross-reference, even though the same section does use "as above" for step 2, showing the author cross-references where intended.\n\nWhy the wording matters operationally. Yanking on PyPI is a per-project, per-version action — there is no single "yank the release" operation that covers both distributions. So "yank it" cannot be read as implicitly covering both packages; the stable section's author evidently agreed, since they spelled out the dual yank there. A release manager following the pre-release section verbatim performs one yank (mcp) and moves on.\n\nConcrete walk-through. (1) v2.1.0b1 is tagged on main; publish-pypi.yml builds and publishes mcp==2.1.0b1 and mcp-types==2.1.0b1 (the workflow runs uv build --package mcp and uv build --package mcp-types). (2) The pre-release turns out to be broken — say the break is in the generated types themselves. (3) The release manager opens RELEASE.md, follows pre-release step 5 verbatim: yanks mcp==2.1.0b1, sets its yank reason to point at b2, cuts v2.1.0b2. (4) mcp-types==2.1.0b1 remains live on PyPI, un-yanked and with no annotation pointing at the replacement. (5) mcp-types is a supported standalone artifact — .github/workflows/shared.yml has a dedicated "mcp-types installs and imports standalone" check — so a type-only consumer using --pre or a pre-release-naming specifier (e.g. mcp-types>=2.1.0b1) still resolves the broken build with no yank warning.\n\nWhy nothing else in the doc closes the gap. The pre-release section never cross-references the stable yank step, and the "one release" rule lives only in the stable section — a reader executing the pre-release runbook has no procedural reason to consult the stable section's step 5. This is also not a pre-existing gap being carried forward: the old doc had the same one-package wording, but this PR is the change that introduces the yank-together rule, so scoping it to only one of the two sections it applies to is an in-scope inconsistency of this rewrite.\n\nImpact and fix. Impact is limited — pre-releases only install when explicitly requested, so the blast radius is consumers who opted into the exact broken mcp-types pre-release — but this file is the operative runbook, and following it verbatim produces an incomplete yank. The fix is one clause in step 5 of the Pre-releases section, e.g.: "If a pre-release turns out to be broken, yank both distributions on PyPI (as above) and cut the next one, ..."

Loading