v3.4.0 release notes
Unzip, Untar and Extract Files have been dead since v1.0.0. Not degraded — dead. All three
were advertised in tools/list for eleven releases, and all three returned either an error thrown
from inside the presenter after the operation had already succeeded, or an archive listing with
every filename correct and every file zero bytes long.
Nothing caught it because the second of the two defects cannot be reproduced in-process. Every test that touches baking imports the module that hides it. Finding it took ruling out six other hypotheses first, and the answer turned out to be the one thing an in-process test cannot vary.
The rest of this release is what measuring the things v3.3.0 carried forward actually produced: three of them changed, two of them did not, and the two that did not are written down as measurements rather than as opinions.
The archive operations
Section titled “The archive operations”Defect 1 — a function that rejected the type its own documentation passes it
Section titled “Defect 1 — a function that rejected the type its own documentation passes it”Utils.readFile’s JSDoc example is literally await Utils.readFile(new File(["hello"], "test")).
Its Node branch was:
return Buffer.from(file).buffer;Buffer.from accepts a string, a Buffer, an ArrayBuffer or an array-like. The File shim is
none of those, so this throws ERR_INVALID_ARG_TYPE. Chef.bake presents every dish
unconditionally and the presenter calls this once per archive member — so the operation failed
after run() had succeeded, and the caller saw an error about a type conversion rather than
anything about their archive.
Fixed by patches/fork/11-utils-readfile-accepts-file.patch. Not by hand: src/core/** is mirrored
from upstream on every sync, and a hand-edit there is exactly what
the SafeRegex incident was — a
mitigation silently reverted, gone for four releases while three documents said it was active. The
patch is verified to apply against a pristine git show HEAD: copy and reproduce the working tree
byte for byte.
Defect 2 — a global that nothing was providing
Section titled “Defect 2 — a global that nothing was providing”Five operations construct a bare global File — Unzip, Untar, Tar, Zip and
Split Colour Channels — and not one of them imports it. In a browser that is the platform’s
File. On Node, src/node/File.mjs stands in, and there is exactly one assignment in the tree:
src/node/index.mjs:516 global.File = File;That is the generated bridge, which this server deliberately does not import eagerly — it pulls
all 504 operation implementations at startup. The optimisation is right. Its side effect went with
it, and new File(...) inside an operation resolved to Node’s own global File: a Blob subclass
with no .data, whose bytes are reachable only through an async arrayBuffer().
The result is the worst available failure mode. Unzip parsed the central directory correctly and
returned members with the right names and zero bytes each. That does not read as a defect. It
reads as an empty archive.
Why a green suite never saw it
Section titled “Why a green suite never saw it”Any harness that has loaded the bridge for any reason puts the shim back, and the bug vanishes. Six
hypotheses were ruled out first — module duplication, the retry wrapper, the cache, the config
bootstrap, worker dispatch, and recipe normalisation (verified by diffing toCoreRecipe’s output
between contexts, which was byte-identical) — before the difference turned out to be whether the
bridge had been loaded at all.
This is the same blind spot as the three releases that shipped 524 tools with an empty
inputSchema, and it has the same answer. tests/mcp/list-file-output.test.mjs spawns the real
server and speaks to it through the official SDK client. It is verified as a regression test
rather than assumed: with the fix removed, 4 of its 6 assertions fail — and the 2 that pass are
exactly the two checking names and the absence of markup, which that defect never broke.
Rendering
Section titled “Rendering”List<File> results are rendered in this server’s own layer rather than by the dish or the
presenter, because neither gives a caller what they unzipped an archive to get. The dish leaves the
value as an array of per-file arrays on Node — deliberate upstream behaviour that
tests/node/tests/NodeDish.mjs:226 asserts — and the presenter emits the browser’s file-list table:
names, sizes, two button labels, and the contents nowhere in it.
=== note.txt (27 bytes) ===FLAG{list-file-round-trip}
=== blob.bin (5 bytes, base64) ===AAEC//4=A member that is not text is base64 and says so, rather than being reduced to lossy UTF-8 silently.
cyberchef_ecdsa_recover
Section titled “cyberchef_ecdsa_recover”CyberChef has ECDSA Sign, ECDSA Verify, ECDSA Signature Conversion and
Generate ECDSA Key Pair. All four work on one signature. Nothing compares two — and that is where
ECDSA fails in practice. The algorithm is sound; implementations leak the nonce.
Two signatures sharing an r used the same k, because r derives from k alone:
k = (z1 - z2) / (s1 - s2) mod nd = (s1 k - z1) / r mod nNo search, no lattice, no factoring — two modular inversions recover the key outright. This is the
PlayStation 3 firmware-signing key and the 2013 Android SecureRandom Bitcoin thefts, and it is
still found in the field.
It is a registry tool rather than an operation for the second of the three documented reasons: a
statistic computed across inputs. Fork applies one recipe per branch and cannot compute
anything over the branches, so “do any two of these share an r” is not expressible as a recipe.
It does not attack a merely biased nonce. That needs an LLL reduction over many signatures, and
claiming it here would be the exactness-honesty violation this project treats as a defect. When
every r is distinct the tool says so and says what that does not prove, with a test asserting
the wording — a caller reading “no reuse found” as “these nonces are sound” would have been misled
by the tool rather than by the mathematics.
No new dependency: the recovery never evaluates a curve point, so only each curve’s order n is
needed. Six entries, not an elliptic-curve library added to an image whose size is tracked.
The tests generate their vectors with a real secp256k1 point multiplication, so r is genuinely
(kG).x mod n. That is not decoration. The easy version — pick d, k, z1, z2 and compute
s1, s2 from the signing equation — proves only that the tool’s algebra matches the test’s, and
both could be wrong the same way. It paid for itself immediately by catching a real defect: the
hash width was derived as digits * 4, which over-reports by up to 3 bits on an odd-length hex
string and truncated a hash that needed no truncation. Widths are rounded up to whole bytes now,
which is what a digest is.
Registry metadata
Section titled “Registry metadata”server.json exists to be machine-read by the MCP registry. Nothing had ever read it. Validated
for the first time, it failed against the schema it declares in six places — because it was
written to the 2025-09-29 schema (camelCase) and declared the 2025-07-09 one (snake_case).
Both URLs resolve, both are real, and the document is internally consistent, so reading it suggests
nothing is wrong.
Both of the registry’s ownership proofs were also absent, so a publish would have been rejected on both packages. Measured rather than inferred:
npm view cyberchef-mcp mcpName (empty, across all 11 published versions)registry.modelcontextprotocol.io/v0/servers?search=cyberchef {"count": 0}Now present: mcpName in package.json, and an io.modelcontextprotocol.server.name annotation on
the image — verified by reading it back off a built image, not by reviewing the Dockerfile.
npm run check:server-json is the gate. It deliberately does not use a JSON Schema validator:
ajv is only a transitive dependency here, and validating against the live schema URL would make a
release gate need the network in a project that ships an air-gapped deployment guide. The rules are
transcribed with a retrieval date, and the checker fails when $schema names a version whose
rules it does not carry — because a checker that silently checks the wrong rules is precisely the
defect that made it necessary.
The performance gate: the baseline moves to the runner, and 20% does not hold
Section titled “The performance gate: the baseline moves to the runner, and 20% does not hold”The baseline was captured on a developer machine while the gate ran on GitHub’s runners. That gap was a machine class, not noise — median -9.6%, worst +43.1% — with a consequence worth stating plainly: a regression smaller than the offset was never catchable on CI. It also produced two false failures in three runs, which is what forced 50% as a stopgap.
benchmark-baseline.yml had been written in v3.2.0 and never run. Its first run failed on
GitHub Actions is not permitted to create or approve pull requests — the same setting
upstream-sync.yml and upstream-monitor.yml had already hit. The setting stays off, because it
also grants Actions the right to approve pull requests; the job pushes a branch and links the
compare view instead. Nothing is lost, because the PR was never the safety property — not pushing to
master is.
So the baseline is now captured on the runner it is compared against. That is the durable result here, and it stands on its own: the systematic machine offset is gone, so the tolerance no longer has to absorb it on top of everything else.
And then the tolerance was cut, and put back
Section titled “And then the tolerance was cut, and put back”Three captures from three runner instances gave a worst spread of 6.7% between their medians, and simulating the gate over 180 comparisons produced zero false failures at 20%, 15%, even 10%. On that evidence the tolerance was cut from 50% to 20%.
Four real CI runs on this branch disproved it the same day.
To Hex (100KB) -41.8%To Hex (10KB) -37.0%Frequency distribution (100KB) -29.2%Entropy (100KB) -26.2%Regular expression (1KB) +28.2% <- on the SAME runRegular expression (10KB) +24.0%Both directions at once, on tasks no commit in this release touches — operation-benchmarks.mjs
imports bake from src/node/index.mjs, and the generated bridge does not reach a single changed
module. That is a different host: the memory-bandwidth-bound tasks lost a third to two-fifths of
their throughput while the CPU-bound one gained a quarter.
The study’s flaw was sampling, not arithmetic. Three captures at 11:13, 11:29 and 11:35 UTC landed on one class of host, and measuring three similar machines cannot tell you what a heterogeneous pool does. The document even said so while choosing 20% over the 10% its own data allowed — being aware of a bias is not being protected from it.
So the tolerance is 50%, which covers the -41.8% actually observed. The study is kept in full, with its disproof, because the shape of the error is more useful than the number.
The number is not the lever worth pulling, and widening it further would not help either. Two mechanisms would, and both are follow-ups with their own validation: a median of several runs in CI (the baseline is a median of four and the gate compares a single run against it, which is asymmetric), or normalising against a calibration task, which targets host-class difference directly — the thing that actually broke this.
Measured and deliberately unchanged
Section titled “Measured and deliberately unchanged”Three carried-forward items were measured and left alone. Each is recorded with what would change the answer, so the next release re-decides on evidence rather than re-arguing.
The shell-free base image. latest-slim genuinely has no shell, builds, runs, and returns
byte-identical output on every fragile operation tested — the wasm path (Argon2 produced the same
encoded hash), the vendored crypto path (HMAC, SM3), the OpenSSL-legacy path that patch 06
exists for (LM Hash), and the capstone path that patch 05 exists for — for 26 MB less
(475 → 449 MB). It is still declined: it ships Node v25.9.0 against the current v26.8.1,
which is a downgrade for every existing user and a version neither CI job tests. There is no
node26-slim tag. What would change the answer: a Chainguard slim tag on Node 26.
--requirements in the conformance gate. The CLI’s own help says it replaces --suite. Here it
is 13 scenarios against 40 — ten of them tasks-*, for a capability this server deliberately
declines — and it drops the resources, prompts and caching scenarios that validate v3.0.0.
The stdio era. A bare initialize naming 2026-07-28 is answered 2025-11-25, which looks
alarming and is correct: the SDK selects the era from an envelope claim in params._meta, so a bare
initialize is by definition a legacy handshake whatever version it names. With a real modern
opening, stdio serves 2026-07-28 with cache hints, verified.
Client limits, checked rather than assumed
Section titled “Client limits, checked rather than assumed”surface tools payload longest description over a 128-tool capindex 41 42,901 614 nocurated 119 106,147 614 noall 544 423,305 614 YESThe default index clears a 128-tool client cap with 87 tools of headroom, and no description comes
near a 2 KB truncation limit. The published surface figures had drifted 325 bytes low across all
three, recorded during v3.3.0’s development and never re-measured before the tag — which is the
argument for npm run measure:surfaces over the numbers.
Verification
Section titled “Verification”npm run lint zero errorsnpm run check:versions every location agreesnpm run check:server-json validates, and the ownership proofs matchnpm test 241/241 Node API, 2289/2289 operationsnpm run test:mcp 1640/1640 across 68 filesnpm run conformance both eras match the baselinedocker build 475 MB, boots, LABEL verified off the built imageUpgrading
Section titled “Upgrading”Nothing breaks. The archive operations start working, which is a behaviour change only in the sense that they previously produced nothing usable. The performance gate is stricter, which affects contributors rather than deployments — and its baseline moved, so a comparison against a local run on your own machine is no longer like against like. Use the workflow.