Skip to content

v2.3.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.2.0 was found by connecting the server to a real client. v2.3.0 was found by installing it the way a user would — packing the tarball, installing it into an empty project with dependency install scripts blocked, and running the binary that came out.

That one act produced almost everything below, as a chain in which each finding was only reachable once the previous one was fixed:

found by what it turned out to be
F-01 packing and installing the package a dependency that cannot be loaded as published
F-02 probing an image operation to check the install 17 operations returning the process’s shared buffer pool
F-03 the test written for F-02 an operation that had never worked in this fork since v1.7.1
F-04/05 checking the SDK before writing against it the protocol migration was a fraction of its estimate
F-06 checking the roadmap’s own next item a transport MCP does not define
F-08/10 chasing uncovered branches a request that hung forever, and an unvalidated argument type

None of them would have surfaced from reading the code.

v2.2.0 v2.3.0
Protocol revisions served 2025 era only 2025 era + 2026-07-28, on stdio and HTTP
Transports stdio, Streamable HTTP stdio, Streamable HTTP, Unix socket / TCP
npm distribution blocked unblocked (publishable; not yet published)
Image operations that work 1 of 18 18 of 18
Coverage / gate 93.5/84.7/93.0/94.4, gate at 75/70/90/75 95.5/88.7/96.3/96.4, gate at 95/88/96/96
MCP tests 955 1,023

The server now answers the current MCP revision on both stdio and HTTP, alongside the 2025 era, from one set of handlers. Existing clients are unaffected — a v1-SDK client still negotiates 2025-11-25 against the same registrations, and tests/mcp/protocol-eras.test.mjs spawns the real binary once per era so the two cannot drift apart.

The plan budgeted this as the release’s second-largest technical risk, on the assumption that reaching the modern era required rewriting both request handlers into per-tool registerTool registrations. Measured against the published packages, it did not:

  • LATEST_PROTOCOL_VERSION is 2025-11-25 in both SDK generations. Read on its own that says the migration buys nothing. It is misleading: 2026-07-28 is not negotiated through initialize at all, but through a per-request _meta envelope claim and a server/discover probe, so the constant only ever described the legacy handshake.
  • The low-level Server already in use serves it. A spike had one low-level server answer a v2 client pinned to 2026-07-28 and a v1-SDK client on 2025-11-25. registerTool is an optional refactor for testability, not a prerequisite for the protocol.
  • The era decision lives in the entry, not the transport. A bare StdioServerTransport plus server.connect() serves the 2025 era only: a modern-pinned client fails outright with ERA_NEGOTIATION_FAILED, because nothing answers its probe.

So the migration came to seven handler registrations moving from a Zod schema to a method string, stdio routed through serveStdio, and a transport swap whose options match field for field. The runtime dependency count fell from 490 to 424.

On HTTP the two eras are routed per request by isLegacyRequest — the entry’s own classification step exported as a predicate, so the branch cannot disagree with the handler it dispatches to. 2025 traffic keeps the sessionful wiring (session ids, idle sweep, capacity limit); modern traffic is served per request with legacy: "reject", so the entry never serves its own stateless fallback and there is no second, unaccounted route to the same tools.

One security note, which matters more than the feature. The modern entry is documented as validation-free and expects the caller to put Origin/Host checks in front of it. The sessionful transport gets that from enableDnsRebindingProtection; the entry has no equivalent. Dropping the modern leg in without its own check would have left a DNS-rebindable path to the same server, reachable by adding a _meta claim to a request — a hole opened by a feature, in the file whose whole purpose is that the HTTP surface is safe. The same allowlist now gates both legs, and a test forges a Host against the modern path specifically.

CYBERCHEF_TRANSPORT=socket serves the stdio binding over a Unix domain socket or a loopback TCP stream — the SDK’s own documented custom-transport route, so an existing client speaks it with no private extension. Each connection gets its own entry, which pins one server instance for that connection’s lifetime, so this inherits issue #36’s isolation without needing session ids: the socket is the session.

Terminal window
CYBERCHEF_TRANSPORT=socket CYBERCHEF_SOCKET_PATH=/run/cyberchef-mcp.sock npx cyberchef-mcp
Variable Default Meaning
CYBERCHEF_SOCKET_PATH (unset) Unix domain socket path. Mutually exclusive with the port. Created mode 0600.
CYBERCHEF_SOCKET_PORT (unset) TCP port. Mutually exclusive with the path.
CYBERCHEF_SOCKET_HOST 127.0.0.1 TCP bind address.
CYBERCHEF_SOCKET_MAX_CONNECTIONS 16 Concurrent connection cap.
CYBERCHEF_SOCKET_ALLOW_REMOTE false Required to bind a non-loopback address.

This transport has no authentication, because the stdio binding’s security model is that the peer already has access to your process. On a Unix socket that is enforced by file permissions; on TCP it is enforced by nothing. Hence three refusals: a non-loopback TCP bind is refused unless explicitly allowed, the socket is created 0600 rather than at the mercy of the umask, and a stale socket file is probed by connecting before it is removed — existence cannot distinguish a crashed process’s leftover from a running server or from somebody’s data, and deleting the wrong one would be silent destruction.

The roadmap’s line for this release read “WebSocket, Streamable HTTP, SSE (deprecated)”. Checked against the specification and the SDK rather than carried forward:

  • Streamable HTTP shipped in v2.0.0 and now serves both eras. Already done.
  • SSE — the HTTP+SSE transport of 2024-11-05 — is deprecated by the specification and, under SEP-2596’s grandfathering policy, eligible for removal. Implementing it now would add a transport the spec is deleting.
  • WebSocket is not an MCP transport. The specification defines stdio and Streamable HTTP and nothing else, and SDK v2 ships no WebSocket transport in server, node or client — zero occurrences across all three packages. Building one would produce a transport no existing client could speak.

The socket binding is the spec-sanctioned thing the theme was actually reaching for. The reversal is recorded in docs/planning/ROADMAP.md rather than quietly dropped, which is the rule the withdrawal of DEP001/007/008 set in v2.0.0.

v2.2.0 deferred npm publishing because npm 12 blocks dependency install scripts by default and this project needed one. The deferral was right; the diagnosis stopped one level short.

crypto-api@0.8.5 cannot be loaded as published. Its package.json declares "main": "index" and the tarball contains no index.js — the files allowlist names three index artefacts, none of which were ever included — so there is no bundle to fall back on. And its ESM sources use extensionless relative imports, which Node’s resolver rejects. Upstream CyberChef works around the second by rewriting specifiers inside node_modules from postinstall, which works for a checkout and cannot reach anyone installing from a registry.

So the blocker was never packaging: it was a dependency that must be patched to be usable at all. It is now vendored at src/vendor/crypto-api/ (MIT, 24 files, byte-identical apart from appending .mjs to relative specifiers in 16 of them), with the seven src/core importers repointed by fork patch 08.

A second blocker surfaced once that cleared: terser was a devDependency but is imported at runtime by JavaScript Minify, and src/node/index.mjs imports every operation. It survived as a devDependency because upstream bundles the web app with webpack and never ships src/core unbundled — this fork does.

Verified end to end rather than asserted: npm pack, npm install --ignore-scripts of the tarball into an empty project on npm 12.0.2, then the installed binary over stdio. initialize, cyberchef_md5, and an SM3 recipe (which runs through the vendored crypto-api) all answer correctly. The package is publishable and has not been published; server.json deliberately still carries no npm record, because advertising a package that is not on the registry sends clients to an install that 404s.

18 image operations, 17 of which were returning heap

Section titled “18 image operations, 17 of which were returning heap”

Every jimp-backed operation ended run() with return imageBuffer.buffer. A Node Buffer is a view: for a small allocation Node hands back a window into a shared pool, so .buffer is not the image. Measured directly, a 129-byte PNG came back as a 65,599-byte ArrayBuffer at byteOffset 32, whose first bytes were unrelated heap contents.

Two consequences, and the second is the serious one:

  • The image was unreadable. present() reads from offset 0, finds no magic bytes, and throws Invalid file type. — from the presenter, after run() had already succeeded, which is why the message pointed nowhere near the cause and why this survived unnoticed.
  • The surplus 65 KB was whatever else the process had recently allocated. On a server handling more than one caller — which is what this project is — that is other callers’ data returned to whoever asked for an image. That half is an information disclosure, not only a correctness bug.

This is upstream’s defect and upstream has already ruled on it: GenerateImage.mjs carries the slice fix with a comment citing the Node documentation, applied to one operation and not to the 17 siblings. Fork patch 09 applies upstream’s own form to all of them. Scope was measured, not assumed: every other return <var>.buffer in src/core was probed by running the operation and counting output bytes, and all were exact — only the jimp-backed operations receive a pooled Buffer.

Add Text To Image was the eighteenth, and had never worked here at all. It loaded four bitmap fonts through webpack-only import() under src/web/static/fonts/bmfonts/ and resolved them against self.docURL. This fork removed src/web/ in v1.7.1 and runs under plain Node, where neither exists — so every call failed with Error preparing fonts. while the tool stayed advertised in tools/list, in every release since v1.7.1. Restoring the assets alone would not have fixed it: Node cannot import() a .fnt, and there is still no document URL. The fonts are vendored at src/vendor/bmfonts/ (Apache-2.0) and fork patch 10 loads them from disk.

Thresholds were 75/70/90/75 while actual coverage was 93.5/84.7/93.0/94.4. A gate set twenty points below actual cannot fail. codecov.yml was worse: project 70%, patch 75%, range: 70..90, so a 94% project painted solid green with nothing left to aim at.

Coverage is now 95.5/88.7/96.3/96.4, raised by testing the reachable gaps rather than the number, and src/node/worker.mjs is measured for the first time — it was absent from the include list, so it appeared in no report while being our own code on the worker-threads path.

The gate uses two tiers rather than perFile: true: src/node/lib/** aggregates to 99.1/94.4/100 and is held there, while the global bar sits just under actual. perFile is off for a specific reason rather than reluctance — two files carry the whole deficit, and both are dominated by SDK error callbacks and handler bodies that only become individually testable after the registerTool decomposition, which this release established is not needed for the protocol. Forcing it now would be a large rewrite bought purely to move a number.

The gate was verified to fail. Raising the branch bar to 99 against a snapshot produced exit code 1. A gate never seen failing is not known to work.

  • A failing operation on the progress path hung the request. streamOperationWithProgress ended its bake with .catch(err => { throw err; }) inside a promise nobody held: the rejection was unhandled and resolve was never called, so the await below it waited forever. It is live code, reached from two call sites. The existing test documented the bug without recognising it — “throws synchronously to avoid unhandled promise rejection” — so the one case that mattered was the one it avoided. Proved against a snapshot: the new test times out at 30s on the old code and passes in 6ms on the fix.
  • argSelector arguments were validated by nobody. 21 arguments across 19 operations, including AES Encrypt and AES Decrypt, are a closed set, but validateOperationArguments had no case for them and they fell through to the flexible default. An invalid mode passed recipe validation and failed much later inside the engine, with an error naming the operation rather than the argument.
  • The shutdown log line no longer says “HTTP sessions”; the socket transport has a closeAll too, and naming the wrong transport in a shutdown message sends an operator to debug the wrong thing.
  • vitest’s hookTimeout was never set, so beforeAll — where a suite builds its 504-operation tool surface — inherited the 10s default while the heaviest files sat at 9.5–9.9s. Adding one more parallel suite tipped two of them over, reported as whole-file failures that looked nothing like “the runner was busy”. Both timeouts are now 30s.
  • Unix socket paths are length-checked up front: sun_path is a fixed 108-byte field (104 on macOS), and the kernel’s answer is a bare EINVAL naming the path but not the reason.

No action required. Nothing in this release is breaking:

  • Existing clients keep negotiating the 2025 era against the same handlers.
  • stdio and HTTP behave as before for 2025-era traffic; the socket transport is opt-in.
  • No tool was renamed, removed or given different arguments. Image operations that previously returned Invalid file type. now return the image.

Two changes are worth knowing about if you script against internals: createTransport returns transport: null for stdio as it already did for HTTP (the entry owns the connection now), and @modelcontextprotocol/sdk is a devDependency rather than a runtime one.

Terminal window
docker pull ghcr.io/doublegate/cyberchef-mcp_v2:2.3.0

Then point a real MCP client at it — which is the authoritative check, and the reason is this project’s own most expensive lesson: raw JSON-RPC does no schema validation, so three releases shipped with every tool carrying an empty inputSchema while hand-written probes reported success.

{ "mcpServers": { "cyberchef": {
"command": "docker",
"args": ["run", "-i", "--rm", "ghcr.io/doublegate/cyberchef-mcp_v2:2.3.0"] } } }

A one-line JSON-RPC ping is still useful as a container smoke test — it proves the image starts and answers — but it proves nothing about whether a client can use the answer:

Terminal window
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | docker run -i --rm ghcr.io/doublegate/cyberchef-mcp_v2:2.3.0
Gate Result
MCP tests 1,023 across 38 files
Node API tests 241
Operation tests 2,289
Coverage 95.51% statements / 88.65% branches / 96.34% functions / 96.35% lines
Lint clean
Packaged install npm install --ignore-scripts of the packed tarball starts and serves
npm audit --omit=dev 4 low, all the same transitive elliptic advisory reached through crypto-browserify. Identical to master, so not introduced here; carried knowingly rather than silently.

Full working record, including what was measured and what was wrong about the plan: docs/internal/v2.3.0-findings-log.md.