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
Highlights
Section titled “Highlights”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 |
Protocol revision 2026-07-28
Section titled “Protocol revision 2026-07-28”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_VERSIONis2025-11-25in both SDK generations. Read on its own that says the migration buys nothing. It is misleading: 2026-07-28 is not negotiated throughinitializeat all, but through a per-request_metaenvelope claim and aserver/discoverprobe, so the constant only ever described the legacy handshake.- The low-level
Serveralready 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.registerToolis an optional refactor for testability, not a prerequisite for the protocol. - The era decision lives in the entry, not the transport. A bare
StdioServerTransportplusserver.connect()serves the 2025 era only: a modern-pinned client fails outright withERA_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.
Socket transport
Section titled “Socket transport”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.
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.
Why not WebSocket
Section titled “Why not WebSocket”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,nodeorclient— 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.
npm distribution, unblocked
Section titled “npm distribution, unblocked”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 throwsInvalid file type.— from the presenter, afterrun()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.
The coverage gate was not a gate, again
Section titled “The coverage gate was not a gate, again”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.
Two defects the coverage work found
Section titled “Two defects the coverage work found”- A failing operation on the progress path hung the request.
streamOperationWithProgressended its bake with.catch(err => { throw err; })inside a promise nobody held: the rejection was unhandled andresolvewas never called, so theawaitbelow 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. argSelectorarguments were validated by nobody. 21 arguments across 19 operations, including AES Encrypt and AES Decrypt, are a closed set, butvalidateOperationArgumentshad 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
closeAlltoo, and naming the wrong transport in a shutdown message sends an operator to debug the wrong thing. vitest’shookTimeoutwas never set, sobeforeAll— 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_pathis a fixed 108-byte field (104 on macOS), and the kernel’s answer is a bareEINVALnaming the path but not the reason.
Upgrading
Section titled “Upgrading”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.
Verification
Section titled “Verification”docker pull ghcr.io/doublegate/cyberchef-mcp_v2:2.3.0Then 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:
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.