Skip to content

v4.2.0 release notes

Released: 2026-09-12 Upstream base: CyberChef v11.4.0

A meta-tool that declares a run handler in the META_TOOLS literal is now dispatched from that declaration, so its name, description, schema and handler live in one entry instead of a literal and a dispatch branch that nothing checked agreed with each other. Eight of the ten recipe tools moved; the dispatch chain went from 24 if occurrences to 16. No behaviour changed, and that is the claim this release spent most of its effort proving rather than asserting.

The charter is one page and it makes two load-bearing claims. Neither survived.

“The dispatch table has never had its version”

Section titled ““The dispatch table has never had its version””

It has, since v3.7.0. tests/mcp/meta-tool-parity.test.mjs asserts exactly the two directions this release set out to gate, and its own header cites the same v3.0.0 carried-forward item as the reason. So the headline deliverable had shipped three releases earlier, and a release about removing duplication began by building a second copy of a check.

It is still worth having done, because of how it was already solved. The v3.7.0 gate is syntactic — it greps for if (name === "cyberchef_…") and compares the set against the literal — and F-01 predicted before any code that a syntactic check would be fragile here. The prediction landed harder than expected: the refactor this very charter asks for breaks it, because eight tools dispatched from a table have no branch to grep. A gate that forbids the change it was written to protect is not a gate, it is a lock.

So it is superseded and removed rather than patched. Patching it to also read the table would leave two overlapping checks with the more fragile one authoritative, which is the two-sources problem this release exists to remove. Its one assertion behaviour cannot reach — a meta-tool shadowing a registry tool, which ToolRegistry.register catches only in the other direction — is carried into tests/mcp/dispatch-table.test.mjs and strengthened to derive names from ToolRegistry.exposedName rather than from a filename regex.

“They differ only in the method called”

Section titled ““They differ only in the method called””

True for eight of ten. The two exceptions differ in ways the shape does not show:

Tool What was hidden
cyberchef_recipe_execute Calls validateInputSize(args.input) first, and returns the recipe’s own string result rather than a JSON envelope
cyberchef_recipe_export exportRecipe already returns a string; the table’s JSON.stringify encoded it twice, so callers got "{\n \"id\": …}" instead of the document — and it round-trips through recipe_import, so this broke a pair rather than only cosmetics

Folding them in silently dropped an input-size guard and changed two output shapes. The charter’s own kill criterion reads “a tidier dispatch that answers differently is a regression with good intentions”, and the regression happened on the first attempt. What stopped it shipping was the pre-existing suite — in-process-handlers.test.mjs and an executable example — not the new gate, which asks only whether a tool is reachable, and both remained perfectly reachable while answering differently.

Both keep their branches, with the reason written where the next person will try to remove them. The table holds 8 of 10; a table with two special cases inside it is a table of exceptions.

The gate is behavioural, and its second direction was circular

Section titled “The gate is behavioural, and its second direction was circular”

tests/mcp/dispatch-table.test.mjs calls every advertised tool through a real client on all three surfaces and reads the answer, distinguishing “the handler rejected your arguments” (a pass — the tool is reachable) from “no such tool” (a failure). The distinction is keyed to the error code, because the word “unknown” appears in both answers and means opposite things.

The second direction — every callable meta-tool is advertised — was circular on the first attempt: it read the META_TOOLS literal and compared it against tools/list, so deleting a declaration removed the name from both sides at once and the check still passed. Proven by deleting cyberchef_quota_info’s entry while leaving its dispatch branch, the exact “callable but invisible” defect, and watching all three tests stay green. It is rebuilt as syntax proposes, behaviour decides: the dispatch chain is scraped only for candidates, and each unlisted candidate is then called to settle whether it really dispatches.

before after
dispatch `if` occurrences 24 16
tools dispatched by table 0 8
mcp-server.mjs statements 85.40% 85.56% (357/418 -> 338/395)
mcp-server.mjs branches 68.04% 70.39% (164/241 -> 164/233)
mcp-server.mjs functions 91.48% 92.85%
global statements 95.30% 95.50%
global branches 89.31% 89.88%

Coverage moved the right way and for the right reason: 25 fewer statements and 10 fewer branches to cover, with the covered count essentially unchanged.

Two of mcp-server.mjs’s uncovered branches are there on purpose. The table dispatch guards against a handler returning a value JSON.stringify cannot serialise, which would otherwise surface as a bare TypeError from Buffer.byteLength naming neither the tool nor the cause. No handler does that today — all eight were called and checked — so the guard is unreachable by construction and therefore uncovered, and it costs about 0.03% of global statement coverage. The alternative offered in review was JSON.stringify(value ?? {}), which is covered, cheaper, and wrong: it serves {} to the caller and turns a handler bug into a silent empty answer. Verified to fire, by making a handler return undefined:

Error [INVALID_INPUT]: Tool 'cyberchef_recipe_list' returned no serialisable value
- tool: "cyberchef_recipe_list"
- returned: "undefined"
``` The sprint asks whether it moved on the
theory that a consolidation which does not move it has probably moved complexity rather than removed
it.
## Compatibility
**No behaviour changes, and no configuration changes.** Every meta-tool answers exactly as it did in
v4.1.0. Two instruments in `docs/internal/measurements/` exist to make that checkable rather than
believable, and both were run across the consolidation:
- `list-contract-snapshot.mjs` — the whole `tools/list` response: descriptions, `inputSchema`,
annotations, titles, and the order the 2026-07-28 deterministic-ordering SHOULD rests on.
**Byte-identical** on all three surfaces: 21,185 / 145,016 / 583,253 capture bytes. Those are
pretty-printed snapshots of the tools array and are deliberately **not** the canonical surface
figures (`npm run measure:surfaces`: 15,620 / 109,548 / 426,706, the compact wire size of the
whole result), which this release does not move. Identical captures are the stronger statement of
the two, since they compare every field rather than a total.
- `meta-tool-answers.mjs` — 27 meta-tool calls through a real client, successes and rejections
alike, with ids and timestamps normalised. **Byte-identical.**
`handleListTools`, including the `visibleTools()` call that filters the list by scope when auth is
on, is unchanged line for line — so the auth-filtered listing is identical by composition over an
identical input.
Both instruments carry the same warning, earned: **compare sizes before believing a clean diff.**
The first run of the first one reported all three surfaces identical when both captures were empty,
because the script had been placed outside the repository and could not resolve the SDK.
## Verification
```text
npm run lint zero errors
npm run check:versions all version references agree
npm run test:mcp 1,728 passed (75 files)
npm run test:coverage 96.44 lines / 89.88 branches / 96.84 functions / 95.50 statements

Every figure above is measured for this release, not carried forward. One that was carried forward did not survive the check: this project’s AGENTS.md recorded 77 MCP test files for a release, and git ls-tree v4.1.0 tests/mcp returns 75.