v3.5.0 release notes
Two of this release’s three items exist because the previous release’s conclusions were tested before being built on, and both turned out wrong. That is the release: less new surface, more of the project’s own claims checked against something outside it.
- The
server.jsongate v3.4.0 shipped was a schema version behind on the day it shipped, and the official tool that says so is one command. - The benchmark fix v3.4.0 recorded — normalise against a calibration task — would not have worked, and its own data proves it.
- The MCP registry ownership proofs v3.4.0 added and verified were never used to publish anything. They are now.
The oracle, applied to server.json
Section titled “The oracle, applied to server.json”v3.4.0 found that server.json had never been validated by anything, fixed it, and added
npm run check:server-json — a hand-transcribed gate. It shipped encoding the 2025-09-29
schema. The current one is 2025-12-11.
The official mcp-publisher CLI reports it in one line:
$ mcp-publisher validate⚠️ Deprecated schema detected: .../2025-09-29/server.schema.json Expected current schema: .../2025-12-11/server.schema.json✅ server.json is validThis project already knows that an official oracle beats an in-tree check — it is written down for
the conformance suite, where the same mistake cost v3.0.0 a release claiming conformance on its own
tests four weeks after an official suite was published. The rule was not applied here because
server.json did not look like the kind of file that has an oracle. It does.
So CI now runs both: the transcribed checker as the blocking, offline gate, because the product
ships an air-gapped deployment guide and a downloaded binary cannot serve one; and
mcp-publisher validate as the oracle that can contradict its author. The deprecation warning does
not fail the tool, so the step greps for it and fails — a deprecated declaration is exactly what it
exists to catch.
What the migration actually involved
Section titled “What the migration actually involved”The changelog says of 2025-12-11 only “Migration: No changes required”. True of the document, false of a checker — read from the schemas rather than the changelog:
definitions added Icon, LocalTransport, RemoteTransportdefinitions removed Server (merged into ServerDetail)Package.required [registryType, identifier, version, transport] -> [registryType, identifier, transport] version is now OPTIONALServerDetail + title (max 100), icons, _meta, remotesThe transcribed rules for name, description, version and the transport types are unchanged, so
nothing false was being reported. But the checker was stricter than the schema on
Package.version, and a checker stricter than the schema rejects documents the registry accepts —
its own kind of wrong answer. Two fixtures pin the new behaviour: a package with no version is
accepted, one with a non-string version is not.
Benchmarks: compare on one host instead of estimating the host
Section titled “Benchmarks: compare on one host instead of estimating the host”v3.4.0 cut the regression tolerance to 20%, CI disproved it within the hour, and the findings log named the fix:
Normalise against a calibration task. Measure one fixed, well-understood operation each run and scale the rest by it.
Tested against the four runs that motivated it, before building it. Normalising by a calibration task is arithmetically the same as dividing out the run’s median:
run median delta vs baseline residual spread AFTER normalising33874729332 +101.4% -32.8% .. +33.7%33875552658 -2.3% -7.8% .. +4.0%33876193748 +1.1% -39.7% .. +11.7%33877632342 +2.2% -44.3% .. +26.5%The correction is worth about two percentage points. Residuals of -39.7% and -44.3% against raw deltas of -38.6% and -42.1%. The hosts do not differ by a scale factor — they differ in profile. Memory-bandwidth-bound tasks collapse while a CPU-bound one improves on the same run, and no single constant corrects two directions at once. That table also says that across four runs the pool’s median throughput varied by a factor of two.
So the host is removed from the comparison rather than estimated. On a pull request,
performance-benchmarks.yml benchmarks the merge base and the head in one job on one
runner, using a git worktree, and compares those two. Whatever that host is, both sides got it.
Measured on a GitHub runner — the environment that produced the range above — on this release’s
own pull request, against code that changes nothing in the benchmarked path:
tasks compared: 30 median +0.5% worst -2.2% best +6.0%Per task, same code, two ways of measuring it:
vs stored baseline (v3.4.0) same hostTo Hex (100KB) -41.8% -2.0%To Hex (10KB) -37.0% -1.4%Regular expression (1KB) +28.2% +6.0%To Hex (100KB) is the clearest case: a task that read as a 41.8% regression on code that does
not touch it reads as -2.0% when both sides run on the same host. Roughly twenty times tighter
on the environment that broke the previous approach.
It does not gate, deliberately. One runner measurement is not a distribution, and one
measurement is precisely the evidence base that set the 20% tolerance last release — a number that
survived a few hours. Until the spread is characterised, compare-runs.mjs reports, the numbers
appear above the fold in the pull-request comment so they accumulate where someone will see them,
and check-regression.mjs stays the blocking gate.
Shipping it as reporting immediately paid for itself. The guard that decides whether the two
checkouts can share one node_modules was a whole-file diff of package.json and
package-lock.json, and it skipped this very pull request — correctly by its own rule, and the rule
was wrong: a release bumps the version, rewriting both files without changing a dependency. It would
have fired at every release, quietly, looking like caution. It now compares the resolved dependency
tree instead, ignoring the lockfile’s root entry, and is verified in all three directions
(version-only bump, tampered dependency, unreadable lockfile). Had this been a gate rather than a
report, the defect would have surfaced as a mysterious pass.
Two limits, stated because they are load-bearing:
node_modulesis shared between the two checkouts, so a genuine dependency change skips the comparison with a notice. Benchmarking the base against the head’s dependency tree measures neither commit.- It runs on pull requests only. A push has no merge base.
The MCP registry listing
Section titled “The MCP registry listing”v3.4.0 added both ownership proofs and verified them — mcpName on npm, the
io.modelcontextprotocol.server.name annotation read back off a built image. The registry still
listed nothing, because the proofs make a publish possible and publishing is a separate act. Same
shape as the npm record that sat absent for seven published versions.
publish-mcp.yml publishes on the version tag. Three deliberate properties:
- No stored credential.
mcp-publisher login github-oidcexchanges a short-lived, workflow-scoped identity token for registry credentials, soid-token: writeon one job replaces a long-lived secret. - It waits for what it points at. The registry hosts metadata, not artefacts;
mcp-release.ymlpublishes the npm package and images on the same tag, so the job polls npm and fails rather than listing a version that resolves to nothing. - It reads the registry back.
publishexiting 0 is not evidence the listing is live — the same rule that verifies the OCI annotation by inspecting an image and the tag by counting its headings.
check:server-json and check:versions run before authentication, so drift surfaces as drift
rather than as something that looks like an auth failure.
Verification
Section titled “Verification”npm run lint zero errorsnpm run check:versions every location agreesnpm run check:server-json validates against 2025-12-11mcp-publisher validate valid, no deprecation warningnpm test 241/241 Node API, 2289/2289 operationsnpm run test:mcp the suite, across 69 filesnpm run test:coverage exit 0npm run conformance both eras match the baselineUpgrading
Section titled “Upgrading”Nothing breaks. The server, its tools and its surfaces are unchanged — this release is gates,
measurement and a registry listing. benchmarks/baseline.json still gates at 50% and still compares
against a runner-captured baseline; the same-host comparison is additional information, not a new
way to fail.