Skip to content

v3.10.0 release notes

This release executes the v4.1.0 charter. It ships as v3.10.0, and that discrepancy is the first thing worth explaining.

docs/planning/v4/v4.0.0-plan.md concludes that v4.0.0 must not be cut, and the measurement was repeated at the start of this release rather than trusted:

draft changelog EMPTY ("Changes since the most recent release will accumulate here.")
current revision 2026-07-28, no successor
SDK @modelcontextprotocol/{server,node,client} all 2.0.0
conformance 0.2.0-alpha.11
server.json schema 2025-12-11 still newest; every 2026-* date 404s

All four triggers unfired, so the kill criterion fires instead. But 4.1.0 cannot precede 4.0.0. A charter directory numbered v4.1.0 … v4.9.0 quietly assumes the major it explicitly tells you not to cut. The numbering was meant as a reading order — the directory’s README says so — and it still read as a schedule the moment someone executed one.

Every charter now carries a banner saying the number is an ordering, not a schedule, and that a v4 charter executed before v4.0.0 ships as a v3 minor. v2.x ran to eleven minors; v3.x now does too.


Importing the server no longer starts a server

Section titled “Importing the server no longer starts a server”

src/node/mcp-server.mjs called runServer() at module scope with no main-module guard, and runServer().catch ends in process.exit(1). So importing the file — which 23 test files do — started a real stdio server holding a live recipe manager, and armed a process exit inside each of them.

That is not tidiness. During v3.9.0 it took down mcp-test on both Node versions while passing locally twice: four test files repoint CYBERCHEF_RECIPE_STORAGE at a temp directory and delete it in afterAll; a save landed after the delete; the ENOENT reached that catch and killed the vitest worker mid-file. v3.9.0 shipped with those four directories deliberately leaked, as a workaround that said so in the code.

The part that would have broken production

Section titled “The part that would have broken production”

The guard resolves realpaths rather than comparing strings, and that is load-bearing. package.json maps the cyberchef-mcp bin at this file, and npm installs bins as symlinks — so for every npx cyberchef-mcp user, process.argv[1] is the link while import.meta.filename is the target. A naive comparison reports “not main”, starts nothing, and yields a container that exits instantly with no error: a worse failure than the one being fixed, and one every test above would still pass through. It also resolves the Docker CMD ["src/node/mcp-server.mjs"] form, where argv[1] is relative to WORKDIR.

tests/mcp/entry-point.test.mjs pins all of it — import starts nothing, the export surface survives, and the server does start via node <file>, via an npm-style bin symlink, and with the Dockerfile CMD shape intact — plus six in-process cases for the decision itself. import() uses a file:// URL rather than a bare path, because a bare absolute path throws ERR_UNSUPPORTED_ESM_URL_SCHEME on Windows, where the drive letter reads as a scheme.

The four leaked directories are reclaimed — and the first version of this sentence was wrong in a way worth keeping. It claimed “verified by counting /tmp before and after: 40 → 40”, using a prefix filter (cyberchef-{mcp-handlers,mcp-pr,quota,ratelimit}) chosen to isolate the four under repair. That filter did not include cyberchef-bin- or cyberchef-guard- — the two directories the new test file was itself leaking, 33 of them by the time two reviewers pointed at it independently. The measurement was built, by accident, so that it could not see the leak I had introduced.

Both are cleaned up now, and the re-measurement uses no filter at all: total /tmp/cyberchef-* grows by exactly one per run, which is cyberchef-test-store — the setup file’s deliberate per-file store, documented as such. The two prefixes this file creates grow by zero.

A measurement scoped to the thing you are fixing cannot tell you what you broke.


The coverage number was partly the defect running itself

Section titled “The coverage number was partly the defect running itself”

Fixing the entry point dropped coverage below the gate, with nothing deleted and no test removed:

before 96.56 lines / 89.61 branches / 96.6 functions / 95.53 statements
after 95.91 lines / 89.08 branches / 96.48 functions / 94.94 statements FAILS 96/89/96/95

runServer() used to be invoked on every test import — that was the defect — so its body and its catch counted as covered in every run. About 0.6% of this project’s line coverage was the bug executing itself, and the thresholds were calibrated partly against that inflated figure.

Fixed by covering it honestly rather than by moving the bar: isEntryPoint is exported and unit-tested in-process across six cases, and the invocation block is marked /* v8 ignore */ with its reason — it is exercised, by child-process tests, which is the only way to observe a module’s import-time behaviour once the worker has already imported it, and which in-process v8 coverage cannot see. The file already used that marker for signal wiring.

after 96.02 lines / 89.13 branches / 96.6 functions / 95.06 statements exit 0

The margin is thin, and saying so is the point: statements clear by 0.06, lines by 0.02. When a future change trips it, the answer is to cover mcp-server.mjs — 81.5% statements, and already named in vitest.config.mjs as carrying the deficit — not to move a threshold that is finally calibrated against a true number.


The release pipeline stops waiting on a clock

Section titled “The release pipeline stops waiting on a clock”

publish-mcp.yml polls npm for 25 minutes for a package that mcp-release.yml publishes as its last step. The budget was sized from a real timeline and is still right for the normal case — but a fixed budget cannot tell a slow build from a dead one, and v3.9.0 made that difference expensive: the sibling hung at the multi-arch build for 33 minutes, twice, so this job spent its entire budget polling for a package that was never coming, then failed with a message telling someone to go and check the other workflow. Correct, and 25 minutes late.

It now reads the sibling run’s state every fifth attempt. Three outcomes instead of one:

  • npm arrives — done;
  • the sibling finished without publishing — fail now, naming it;
  • the sibling is still running — keep waiting, which is evidence rather than optimism.

completed/success with nothing on npm gets a grace period rather than a failure, because npm says a publish “may take a few minutes to become available” — a fact that cost an investigation in v3.9.0 when a successful publish read as a silent one for several minutes.


The v4 trigger watch is executed, not remembered

Section titled “The v4 trigger watch is executed, not remembered”

v4.0.0-plan.md says v4.0.0 must not be cut because no trigger has fired. That conclusion has an invisible expiry date: the thing blocking v4.0.0 is not effort, it is noticing. The to-do asked for twelve manual checks once per release — and two of the twelve were missing from that file until the day after it was written.

npm run check:v4-triggers polls the five that are machine-checkable and exits 0 nothing fired, 1 fired, 2 could not measure — all three verified by simulation. A monthly workflow runs it and files one tracking issue. It is dependency-free on purpose, so an install cannot be the reason a trigger goes unnoticed, and it reports rather than decides: a fired trigger means run the RE-MEASURE ritual, not cut a major version.

Writing it surfaced one more instance of this project’s recurring defect. A plain fetch of modelcontextprotocol.io returns a Next.js error shell, not the page, so the regex matched nothing — and the first version printed --, the same mark it prints when the page says nothing has changed. Cannot measure and nothing changed are different facts. It now uses the .md routes, and an unreadable page throws.


npm run lint zero errors
npm run check:versions every location agrees
npm run check:server-json validates against 2025-12-11
npm run check:v4-triggers nothing fired
npm test 241/241 Node API, 2289/2289 operations
npm run test:mcp 1737/1737 across 76 files
npm run test:coverage exit 0 — 96.02 / 89.13 / 96.6 / 95.06

Surfaces measured, not recorded: 42 / 44,493 index, 120 / 107,739 curated, 545 / 424,897 all — unchanged, as expected for a release that adds no tools.

Nothing breaks, and one thing is worth knowing if you embed this server: importing src/node/mcp-server.mjs no longer starts a server. If you relied on that side effect — you almost certainly did not, since it also armed process.exit(1) in your process — call createMcpServer() explicitly. Every documented entry point is unchanged: npm run mcp, npx cyberchef-mcp, and docker run -i all behave exactly as before, each covered by a test.