Skip to content

v3.11.0 release notes

This release executes the v4.2.0 charter and ships as v3.11.0, for the reason v3.10.0 established: the v4 charter numbers are a reading order, not a schedule, and a v4 charter executed before v4.0.0 lands as a v3 minor. The trigger watch was run first and nothing fired.

draft changelog EMPTY ("Changes since the most recent release will accumulate here.")
current revision 2026-07-28, no successor
SDK @modelcontextprotocol/{server,node,client} all 2.0.0
conformance 0.2.0-alpha.11
server.json schema 2025-12-11 still newest; every 2026-* date 404s

cyberchef_pqc_identify — the nineteenth registry tool

Section titled “cyberchef_pqc_identify — the nineteenth registry tool”

Zero of the 504 operations touch ML-KEM, ML-DSA or SLH-DSA. That was measured against OperationConfig.json rather than assumed: no operation matches kyber, dilithium, sphincs, ml-kem, ml-dsa, slh-dsa, falcon, lattice or post-quantum. A toolkit that parses RSA, ECDSA, DSA and EdDSA and not the NIST standards has a gap a user will meet.

The new tool names the parameter set behind a key, signature or ciphertext — ML-KEM (FIPS 203), ML-DSA (FIPS 204), SLH-DSA (FIPS 205), all eighteen sets.

Node’s own crypto generates every one of the eighteen parameter sets from Node 24. This project has no business shipping its own lattice arithmetic, and the RE-MEASURE ritual’s “search the ecosystem before you build” step is what surfaced that. It changed the build twice over:

  • Every fixture that is supposed to be real is generated by Node at test time, and the OID/size table is asserted against all eighteen parameter sets rather than a sample of six. A hand-written blob tests the parser against its author’s understanding of the format; a generated one tests it against an implementation that has to interoperate. The malformed structures are necessarily hand-assembled — no implementation emits an OID body of half a megabyte — and exist to drive the rejection paths.
  • The OID and size table was extracted, not cited. Every value came out of a key Node produced, and then agreed with the published FIPS figures — which is the cross-check, not the source.

Given DER — PEM, hex or base64, SPKI or PKCS#8 — it reads the algorithm OID, answers definite, and reports the OID so the answer can be checked rather than trusted. Given raw bytes it has only a length, and a length is evidence, not an identification. So it lists every candidate and declines to choose:

  • 1568 bytes is both an ML-KEM-1024 encapsulation key and an ML-KEM-1024 ciphertext. A tool that answered one would be right half the time and confident always.
  • A 32-byte SLH-DSA public key is indistinguishable from a SHA-256 digest or an Ed25519 key, and 48 and 64 collide the same way.
  • The hash family is not recoverable from a raw signature at all. SHA2-128s and SHAKE-128s signatures are both 7856 bytes, and that holds across every SLH-DSA size.

A non-match is reported as a non-match rather than as absence: 999 bytes matching nothing does not mean the input is not post-quantum.

The first extraction was wrong and looked right

Section titled “The first extraction was wrong and looked right”

The OID table was first pulled by scanning for the 0x06 tag byte near the start of the SPKI. It gave correct answers for five of six algorithms, and this for the sixth:

ml-kem-1024 1.8.11.6.9.96.840.1.101.3.4.4.3.3.262.33.0.2348.414852...

A 0x06 inside a length field, read as a tag. Five plausible results and one absurd one is a lucky failure mode — had the sixth also looked plausible, a wrong OID would have shipped. Replaced with a real DER walk, which agrees with the published values for all eighteen, and pinned by a test that fails if the byte scan comes back.


The coverage margin v3.10.0 warned about lasted one release

Section titled “The coverage margin v3.10.0 warned about lasted one release”

v3.10.0 measured statements at 95.06% against a threshold of 95, wrote down that the margin was thin, and said what to do when it broke: cover the code, do not move the bar.

before 94.99 statements FAILS the 95 threshold
after 95.15 statements exit 0

The deficit was the new tool, and the shape of it is the interesting part: pqc-identify.mjs measured 91.39% statements against 98.64% lines. Nearly every line ran. What did not run were the parser’s rejection paths — a truncated TLV, a PKCS#8 version INTEGER with nothing after it, an AlgorithmIdentifier whose first element is not an OID, an OBJECT IDENTIFIER with an empty body — and every input_format other than Auto.

Those are precisely the paths where a parser invents an answer, and this parser’s predecessor did invent one. The tests were owed regardless of the threshold, and writing them found a real defect: the tool threw a bare Error where every other registry tool throws createInputError, so the same class of bad input reached a client untyped, without the INVALID_INPUT code or the offending field. Fixed, and pinned by a test asserting the code rather than the message.


Four gates caught this release, and the one I had not anticipated was the strongest

Section titled “Four gates caught this release, and the one I had not anticipated was the strongest”

Adding one tool moved five documented figures — the registry-tool count, the three surface tuples, and coverage. Four gates fired between them, and each named its own fix:

registry-tool-docs 19 expected, 18 found, across 7 documents; 4 inventories missing the name
tool-surface-figures table says 42 / 44,493, server serves 43 / 45,963
stdio-client-contract a registry tool with no client-driven call fixture
test:coverage statements 94.99% against a threshold of 95

The third entry is the one worth recording, because it was the only one not already on my list of things to update. stdio-client-contract.test.mjs derives its expected fixture list from the live registry rather than from the fixture table beside it — its own comment explains that an earlier version compared the table against itself, which is an identity that cannot fail. That is the only reason a new tool with no client-driven call surfaced at all.

registry-tool-docs earned its keep the same way: it discovers which documents behave as inventories instead of listing them, and so named docs/guides/commands.md, which no part of the plan mentioned.


What review found, which is the part worth reading

Section titled “What review found, which is the part worth reading”

Both reviewers independently found the same two defects, and both are the failure this tool was written to avoid — a parser that invents an answer — arriving by routes I had not closed.

An OID outside the structure that declares it still produced definite. readTlv bounded every read against the whole buffer rather than against the parent’s declared end, so an AlgorithmIdentifier declared empty, with a real OID as its sibling, was identified with full confidence:

before 300d3000 0609608648016503040401 -> definite / ML-KEM-512
after -> none

A partially-decodable input was partially identified. Buffer.from stops at the first thing it cannot parse and returns the prefix with no error, so 1,312 valid hex bytes followed by garbage decoded to 1,312 bytes and was reported as a probable ML-DSA-44 public key; "zz" as declared hex decoded to zero. Hex, base64 and PEM are now validated in full before decoding, and PEM requires its BEGIN and END labels to match — spliced labels parsed happily before.

Copilot found a third that no fixture could have caught: decodeOid assumed the first subidentifier was one byte. It is base-128 like every other, and X.690 splits it by range rather than by division. Every NIST PQC OID happens to encode in one byte, so the tests were blind to it — but the tool reports the OID for algorithms it does not know, which is exactly where an unusual arc reaches a user:

2.40 -> "3.0" now 2.40
2.100.3 -> "3.9.52.3" now 2.100.3

And one that was already wrong before this release: the user guide said the round-trip was 9.9x cheaper while tool-catalog.mjs said 9.2x and the truth was 8.9x. The surface gate checks that multiplier in tool-catalog.mjs and nowhere else, so it had been stale across releases. The gate now checks the guide too, verified by reintroducing 9.9x and watching it fail — a gate that covers one occurrence of a claim reads as covering the claim, which is this repository’s most-repeated defect and the fourth time it has been found inside a check written to prevent it.

Finally, Copilot caught that the chart’s own version should not have moved. Chart.yaml carries a paragraph beginning “WHY THIS IS NOT BUMPED EVERY RELEASE, since a reviewer has now raised it twice” — and it was bumped anyway. Reverted to 0.5.8, where v3.9.0 and v3.10.0 also left it.

npm run lint zero errors
npm run check:versions every location agrees
npm run check:server-json validates against 2025-12-11
npm run check:v4-triggers nothing fired
npm test 241/241 Node API, 2289/2289 operations
npm run test:mcp 1804/1804 across 77 files
npm run test:coverage exit 0 — 96.11 lines / 89.44 branches / 96.64 functions / 95.15 statements

Surfaces measured, not recorded:

surface tools bytes KB
index 43 45,963 45
curated 121 109,209 107
all 546 426,367 416
index + one operation schema: 47,741 bytes (8.9x cheaper than `all`)

The tool costs 1,470 bytes on the index — 43 tools where v3.10.0 served 42. A registry tool has no navigation path, so one that is not listed cannot be called at all; that is why it is in every surface, and why the index grows with each one.

Nothing breaks. One new tool is advertised on every surface, so a client that pins an expected tool count will see 43 rather than 42 on index, 121 on curated and 546 on all.