Skip to content

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.


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.

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.

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 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 n
d = (s1 k - z1) / r mod n

No 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.


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 run
Regular 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.


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 cap
index 41 42,901 614 no
curated 119 106,147 614 no
all 544 423,305 614 YES

The 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.


npm run lint zero errors
npm run check:versions every location agrees
npm run check:server-json validates, and the ownership proofs match
npm test 241/241 Node API, 2289/2289 operations
npm run test:mcp 1640/1640 across 68 files
npm run conformance both eras match the baseline
docker build 475 MB, boots, LABEL verified off the built image

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.