Skip to content

v2.2.0 release notes

Release Date: 2026-08-31 Upstream Base: CyberChef v11.4.0 (unchanged) Licence: GPL-3.0-or-later Node: >=24 <27

v2.1.0 made 524 tools usable. This release is what happened when the server was connected to a real MCP client and pointed at real work.

Two things produced almost everything below. The first was a battery of 74 cases drawn from the published CyberChef recipe corpus — malware triage, IOC extraction, DFIR, red-team crypto, CTF ciphers, steganography, flow control — run against the published v2.1.0 container rather than against the source tree. The second was registering that container as an MCP server in a client and reading the schema it actually loaded.

Those two found different things, and neither found the other’s:

v2.1.0 v2.2.0
Generate QR Code "" — the PNG deleted the image
Play Media 23 characters of player chrome the audio
Generate all hashes error:0308010C 44 hashes
A misspelled argument silently ignored, wrong answer rejected, with the valid names
cyberchef_bake argument schema positional only, contradicting the implementation both forms
Tools carrying annotations 0 of 527 527 of 527
Prompts / resources none 5 / saved recipes
MCP tests 872 955

Generate QR Code returned an empty string, and always had. Its output type is html and the payload rides in <img src="data:image/png;base64,...">; the html-to-text conversion removed the tag carrying the entire result. Not a v2.1.0 regression — v2.0.0 returned {} for the same call, because it JSON.stringify’d the Dish. The operation has never worked over MCP. Only the failure mode changed.

Play Media lost its audio identically, leaving 23 characters of player chrome where a recording had been.

MCP has carried an image block since its first revision and an audio block alongside it, so one extractor now covers <img>, <audio> and <video>, and the block type is chosen from the payload’s MIME type. Magic-number sniffing (PNG/JPEG/GIF/BMP/WebP, WAV/MP3/OGG) catches the binary path, where an operation such as From Base64 of a PNG produces no markup at all.

RIFF needed care rather than another table row: RIFF....WAVE is audio and RIFF....WEBP is an image, and the first four bytes are identical, so the form type is read separately and a buffer truncated before it decides nothing.

Video is the honest exception. MCP has no video content block, so the data URI is returned as text — unreadable, but recoverable, which is the entire difference between that and stripping it. Returning an image block for a video would be worse than returning nothing.

Binary is deliberately unchanged by default. It looks like mojibake, but that was measured rather than assumed: the latin1 mapping is byte-for-byte reversible (str.charCodeAt(i) equals bytes[i] for every byte of a gzip payload), so nothing is lost. It is a readability problem, not a correctness one, and altering output for 76 operations to fix something that is not broken is a worse trade than an opt-in. CYBERCHEF_BINARY_OUTPUT=base64 opts in.

The hash failure the last release said it had fixed

Section titled “The hash failure the last release said it had fixed”

Generate all hashes failed in the shipped container:

Error [INVALID_INPUT]: Generate all hashes - error:0308010C:digital envelope routines::unsupported

The v2.1.0 notes say --openssl-legacy-provider fixed this. The variable was set — podman inspect confirms it in the image’s NODE_OPTIONS — and it did nothing, because the runtime image has no legacy provider module to load:

$ podman run --rm --entrypoint /usr/bin/node <image> -e "require('crypto').createHash('md4')"
Unable to load legacy provider.
node v26.8.1 openssl 3.6.4

A filesystem walk of the whole image finds no *legacy*.so. This is the same shape as the SafeRegex incident: a mitigation documented as active that the shipped artefact never carried.

The blast radius was one operation, not the twenty it looked like. Running all 21 members individually in the container, 20 pass — MD4, NT Hash and Whirlpool included, because CyberChef implements those in JavaScript and OpenSSL never enters. Only LM Hash reached it, via ntlm@0.1.3’s crypto.createCipheriv("DES-ECB", ...), and single DES is legacy-only under OpenSSL 3.

Fixed by removing the dependency rather than satisfying it: LM Hash is computed with node-forge, already a direct dependency, in pure JavaScript. Both canonical vectors match (LM("password") = E52CAC67419A9A224A3B108F3FA6CB6D). The flag is gone from the Dockerfile and both npm scripts, and nothing needs it — verified by running all 2,289 operation tests on a host that also lacks the provider.

Separately, and worth keeping on its own merits: one failing algorithm no longer discards the other twenty. Every algorithm ran unguarded, so a single throw destroyed every digest that had computed correctly.

Both return data, report success, and are wrong. For a server used in forensic and malware work that is the worst failure mode available — worse than an error, because nothing prompts a second look.

A misspelled argument was silently dropped. Running a three-round base64 decode via Label and Jump returned one round, with no error. The recipe passed {label: "top", maximum_jumps: 2}; the real names are label_name and maximum_jumps_if_jumping_backwards, so both keys matched nothing and the defaults were used:

{label: "top", maximum_jumps: 2} -> ["", 10] // empty label: no jump ever happens

CyberChef’s UI labels sanitise into forms nobody would guess, so this is a mistake a caller will actually make. Unknown keys are now rejected, naming both the offending ones and the accepted ones.

cyberchef_bake advertised the wrong argument shape. Its schema declared args as type: "array" — positional only — while the implementation has accepted named arguments since DEP005, which is the form the entire v2.1.0 usability effort rests on. A client that validates outbound arguments against inputSchema could not send the supported form at all, and cyberchef_recipe_create declared the same concept as an object two tools away. Now anyOf: [object, array].

That second one was found only by connecting a real MCP client and reading the schema it loaded. Neither the test suite nor the container harness had ever compared the advertised argument shape against the accepted one — the same blind spot, in a new place, that let 524 empty schemas ship for three releases.

Every one of the 527 tools now carries readOnlyHint, destructiveHint, idempotentHint and openWorldHint, plus a human-readable title — “AES Encrypt” rather than cyberchef_aes_encrypt. With no annotations a careful client must assume the worst of every tool, so a session that decodes base64, extracts URLs and hashes the result asks for approval three times, for three operations that read their input and return a value.

The exceptions were measured rather than guessed, because an annotation is worth exactly as much as its accuracy:

  • Network reach by grepping src/core/operations/ for fetch/XMLHttpRequest/axios. Exactly two: HTTP request and DNS over HTTPS. They are kept independent of readOnlyHint, because they are: a DNS lookup reaches the network and changes nothing.
  • Non-idempotence by running each candidate twice on the same input and comparing. That is why Bcrypt and Derive PBKDF2 key are marked (both generate a random salt, which reading the names would not reveal) and why Argon2 and Scrypt are not (fixed defaults, identical output both runs). Over-marking would cost real cacheability.

cyberchef_bake is deliberately marked neither read-only nor non-destructive. It runs caller-supplied recipes, which may contain HTTP request with a POST or a DELETE. Marking it read-only would be convenient — it is the primary tool, so a prompting client now prompts often — and false. A hint that is convenient and wrong teaches a client to ignore the whole set. Callers wanting the cheap path have one: call the operation tool directly, where all 504 are annotated from their own behaviour.

Two MCP surfaces this server had never served.

Prompts answer the question the tool list does not. A 24-tool navigation index is the right shape for a model that already knows it wants to base64-decode something, and the wrong shape for someone holding a suspicious blob — the case this server is most useful for and worst at advertising. Five, each encoding a real procedure from the upstream recipe corpus rather than restating the tool list:

Prompt What it encodes
analyse-unknown-data Magic first, because guessing before identifying wastes calls
extract-iocs extract, decode-then-extract, and defang before reporting
deobfuscate-script unwrap layer by layer; the four common chains, in order of frequency
identify-hash Analyse hash, then what actually distinguishes the candidates
decode-chain compose one recipe rather than one call per layer

Resources expose saved recipes at recipe://<id>, with a recipe://{id} template. A saved recipe is reference material — browsed and attached far more often than executed — and MCP separates that from actions deliberately. Reading one previously cost a tools/call a cautious client might prompt for.

Keyed by id, not name, and that is load-bearing: recipe names are user-supplied and not unique, so a name-keyed URI would make one of two same-named recipes unreachable and silently return the other.

Adding these caught a drift worth recording: there are two places a server is constructed — the module singleton backing stdio and the per-session factory backing HTTP — and the first version updated only the factory. Every stdio client, which is most of them, would have been told this server has no prompts while the handlers sat there answering. Both now read one SERVER_CAPABILITIES.

MCP Server CI had been failing on master since the v2.1.0 merge, with all four thresholds under and every one of the 805 tests passing. Two causes:

  • core-ci.yml has no pull_request trigger, and its path filter omitted tests/**. So the MCP suite and the coverage gate were never a merge requirement, and v2.1.0 was reviewed, merged and tagged with the gate already red — it only turned visible on the push to master. A gate that cannot fail before a merge is not a gate.
  • handler-dispatch.test.mjs does not dispatch handlers. Its header claims it tests “all handler branches in the CallTool request handler”; it asserts on re-exported helpers, and createMcpServer() was called by no test in the suite. 263 of 309 statements counted as dead code. Meanwhile the modules covered only through a spawned server measured near zero (tool-catalog.mjs: 2.17%), because v8 attributes nothing a child process does to the parent. “Tested but not measurably tested” is indistinguishable from “untested” to a threshold.

Fixed by testing the handlers, not by lowering the bar — a real MCP Client connected to createMcpServer() over InMemoryTransport:

statements 74.11 -> 93.91 mcp-server.mjs 14.51 -> 87.70
branches 67.36 -> 84.40 tool-catalog.mjs 2.17 -> 97.83
lines 74.44 -> 94.58 tests 805 (26 files) -> 937 (32 files)

Pull requests now run both.

  • A draft recipe can be validated. cyberchef_recipe_validate and _test required id and version — both server-assigned — so they could only check a recipe already saved, which is when checking is least useful.
  • Release notes get their relative links rewritten at publish time. A release body renders on the Releases page, not in the tree, so [text](https://github.com/doublegate/CyberChef-MCP/blob/master/docs/security/foo.md) 404s for every reader of the release. v2.0.0 shipped 8 such links and v2.1.0 shipped 3, all broken. Two mistakes were made writing that fix and both are now pinned by tests: a sed version resolved ../guides/x to guides/x instead of docs/guides/x, and an inline heredoc inside a YAML block scalar would have broken every release (bash -n: “unexpected end of file”).

Packaging, and one thing deliberately not shipped

Section titled “Packaging, and one thing deliberately not shipped”

cyberchef-migrate is a real command at last. docs/v2.0.0-breaking-changes.md has told readers to run it since v1.8.0 and it never existed – only the two MCP tools were ever built, and those are reachable only from inside a session, which is no use to someone holding a directory of recipe files. It shares its analysis with those tools rather than reimplementing it, so they cannot disagree about what a v1 recipe means. It will not rewrite a file without --write, keeps a .bak, and refuses to overwrite that .bak without --force – a second run would otherwise replace the backup with the already-migrated file and destroy the only copy of the original.

Two navigation tools now declare an outputSchema and return structuredContent, so a caller gets a typed object rather than parsing JSON out of a text block. Only those two: the 504 operations return whatever CyberChef returns, undocumented and varying per operation, and declaring a schema for that would be a claim rather than a contract – and a wrong one makes the SDK reject results that are perfectly valid.

npm publishing is prepared and not shipped. The package is cyberchef-mcp (the cyberchef name belongs to upstream), version carries the product version with the upstream base moved to cyberchefUpstreamVersion, and there is a files allowlist, a prepack that generates the two gitignored files the server cannot start without, two bin entries and a server.json. npm pack produces a correct 12 MB tarball.

Then installing that tarball and running it showed it would not work. npm 12 blocks dependency install scripts by default (npm help install-scripts: “Dependency install scripts are blocked by default”), and this project needs one – crypto-api ships extensionless imports that Node’s ESM resolver rejects, so the installed server dies immediately on

Cannot find module '.../node_modules/crypto-api/src/hasher/has160'

Confirmed with a minimal probe package whose only content was a postinstall echoing a marker: it did not run either, so this is npm’s behaviour rather than something about this package.

Publishing anyway would ship something that fails on install for anyone on npm 12+ – the same shape as the --openssl-legacy-provider claim this very release had to correct. Docker and GHCR remain the supported channels and both work. The fix is to stop needing the patch at all, and is v2.3.0 work rather than something to rush into a release.

Three defects found on the way are fixed regardless, and each would have bitten later:

  • postinstall shelled out to grunt – a devDependency a consumer never installs – and to sed. It is pure Node now, which also deletes the macOS/Linux branching that existed only because BSD and GNU sed -i disagree about an argument.
  • The patcher looked in the wrong node_modules: npm runs a dependency’s postinstall inside its own directory while dependencies are hoisted to the consumer root, so every patch reported “target absent” and silently did nothing.
  • src/node/mcp-server.mjs had no shebang, so as a bin entry the shell tried to execute JavaScript as a shell script: cyberchef-mcp: line 1: /bin: Is a directory.
Terminal window
docker pull ghcr.io/doublegate/cyberchef-mcp_v2:latest

No configuration change is required, and no tool is renamed or removed. Three behaviour changes are worth knowing:

  • Image and audio operations now return image/audio content blocks rather than an empty string. A client that expected content[0].text from Generate QR Code was reading ""; it now gets content[0].data.
  • A misspelled argument is now an error rather than a silently-defaulted value. This surfaces latent bugs in existing recipes — which is the point, since those recipes were returning wrong answers.
  • cyberchef_bake is annotated as not read-only, so a client that prompts on non-read-only tools will now prompt for it. Call operation tools directly to avoid that; they are annotated individually.
npm run lint 0 errors
npx vitest run tests/mcp/ 955 passed (33 files)
npm run test:coverage 93.91 / 84.40 / 94.37 / 94.58
npm pack + install + run tarball correct; npx blocked by npm 12 (see above)
tests/operations 2289 passed
tests/node 241 passed
74-case discipline battery 62/62 simple, 12/12 complex, against the built image
container verification QR image, 44 hashes, argument rejection, draft validation