v2.7.0 release notes
Release Date: 2026-09-02
Upstream Base: CyberChef v11.4.0 (unchanged)
Licence: GPL-3.0-or-later
Node: >=24 <27
Highlights
Section titled “Highlights”This release makes the server observable: a Prometheus endpoint, OpenTelemetry tracing, trace correlation in the logs, and the operator-facing assets that turn those into something usable — a Grafana dashboard, alerting rules, and the Kubernetes wiring for both.
It adds one dependency, and that is the most consequential decision in it.
The OpenTelemetry SDK costs more than the last release saved
Section titled “The OpenTelemetry SDK costs more than the last release saved”The plan asked for @opentelemetry/sdk-node integrated directly, plus exporters for “3+ backends
(Jaeger, Datadog, New Relic)”. Measured before installing anything:
| packages | disk | startup | |
|---|---|---|---|
| SDK + OTLP + Prometheus exporters | 71 | 50 MB | +100 ms |
@opentelemetry/api alone |
1 | 2.6 MB | +9 ms |
v2.6.0 spent an entire release taking cold start from ~1300 ms to ~185 ms. Bundling the SDK hands back more than half of that on every launch — and nearly every launch of this server is an editor starting a subprocess on stdio, where there is no collector and never will be.
The obvious objection is that instrumentation you cannot turn on is useless. So that was measured too:
100,000 span + metric cycles, no SDK registered: 8 ms total (0.08 microseconds each)tracer.startSpan(...).isRecording(): falseSo the server depends on the API only. It creates spans and records the conventional histogram; the operator supplies the SDK and picks the exporter:
node --import ./otel-bootstrap.mjs src/node/mcp-server.mjsCold start after the change, 6 runs: 147–164 ms. No regression.
And the plan’s “3+ backends” is beaten by not implementing it — because the operator chooses the exporter, every OTLP backend works rather than three.
A Prometheus endpoint with no dependencies
Section titled “A Prometheus endpoint with no dependencies”CYBERCHEF_METRICS_ENABLED=true # off by default20 metric families on the same listener as /mcp, behind the same routing, CORS and DNS-rebinding
protection. No second port, no prom-client: the exposition format is a few hundred bytes of text
generation, and the server already maintains every counter a scrape wants.
Off by default is a security decision. The health probes are unauthenticated and always on,
because a kubelet probe carries no bearer token and answers one question in one word. /metrics is
unauthenticated for the same reason but says far more — which tools are used, how often, how large
the inputs are, how many tenants are active. That is a reconnaissance surface, so it is opt-in. When
disabled it is not special-cased at all: it falls through to the ordinary 404, so a prober cannot
tell “metrics off” from “not this server”.
Never exposed: tenant identifiers, tool arguments, subject digests, recipe names, error messages. Tenant count yes; tenant names no.
Three defects found by building the dashboard
Section titled “Three defects found by building the dashboard”Designing the dashboard forced the question “is rate() actually valid on this series?”, and the
answer was no — twice.
The counters could go down. Per-tool counts were derived from TelemetryCollector, a
10,000-entry ring that drops its oldest entries. A derived count therefore falls on rollover, and
Prometheus reads a falling counter as a restart — so every rate() over it would have invented
traffic that never happened.
The counters were empty by default. record() returns early unless
CYBERCHEF_TELEMETRY_ENABLED=true, which is off. /metrics on a default deployment would have
reported zero tool calls forever, under any load. An operator reads that as an idle server or a
broken endpoint. A monitoring surface that reads empty under load is worse than none, because it is
believed.
Both fixed with dedicated monotonic counters, incremented before the opt-in gate. Telemetry gates the per-call record — duration, sizes, timestamp — not the fact that a tool ran.
Three # HELP lines for one metric. The lifecycle states were emitted as three families with
the same name. Prometheus rejects a duplicated declaration and fails the entire scrape, so every
other metric would have silently vanished with it.
Anyone could have exploded your Prometheus
Section titled “Anyone could have exploded your Prometheus”Found in the first scrape of a running server, driving real traffic:
cyberchef_mcp_tool_calls_total{tool="cyberchef_definitely_not_a_tool"} 1The tool name reaching the counter is the name the caller asked for. An unknown one is still
dispatched, fails to resolve, and is recorded as a failure. Unbounded, anyone able to call this
server could mint arbitrary Prometheus labels by invoking cyberchef_<random> in a loop — and each
label set is a new time series persisting for the whole retention period, taking out monitoring for
every other service sharing that Prometheus.
Now capped at 1024 distinct tool labels, overflowing into a single __other__ series. The real
catalogue is ~500 tools. Overflow is bucketed, not dropped: a flood of unknown tool names is
exactly what an operator wants to see. Verified — 5,000 attack calls collapse to one series while
real tools keep counting.
Tracing that never records what you are analysing
Section titled “Tracing that never records what you are analysing”The MCP semantic conventions define gen_ai.tool.call.arguments and gen_ai.tool.call.result as
Opt-In. This server does not opt in, and the reason is specific rather than general caution:
the arguments to a CyberChef tool are the sensitive material. A key, a password hash, the document
being decoded. Shipping them to a tracing backend copies exactly what the caller is analysing into a
system with different retention, different access control and usually a longer memory.
error.type is set from the structured error code or the exception class — never the message,
because messages quote their input. A returned MCP error has its code recovered by a pattern bounded
to a bare uppercase identifier, so no matter what an operation writes into an error string, nothing
but a member of the fixed code enum can reach a backend.
Sizes and counts are recorded. Content never is.
A failed tool call does not throw. MCP returns failures as an ordinary result with
isError: true, so a span watching only for exceptions reports every failed operation as a success
— precisely the case an operator is looking for. Handled explicitly.
Log correlation
Section titled “Log correlation”Every log line carries trace_id and span_id when a trace is active — applied as a pino mixin
rather than threaded through the three request helpers, because the useful line during an incident
is invariably one of the others. With no SDK it adds no fields at all, rather than a pair of nulls
every downstream query then has to filter out.
Dashboards, alerts, and the stack that proves them
Section titled “Dashboards, alerts, and the stack that proves them”New under deploy/:
grafana/cyberchef-mcp-dashboard.json |
25 panels across 5 rows |
grafana/alerts.yaml |
9 Prometheus alerting rules |
grafana/README.md |
metric reference, and what is deliberately not exposed |
compose/docker-compose.observability.yml |
Prometheus + Grafana, provisioned and running |
Helm ServiceMonitor + PrometheusRule |
plus classic prometheus.io/scrape annotations |
A dashboard nobody has run is a JSON file with opinions in it. Configuration rots differently from code — it never throws. So all of it was executed:
promtool check rules alerts.yaml 9 rules, SUCCESSpromtool parse of every distinct dashboard query 38 rules, SUCCESSlive Prometheus scraping a live server up, 20 metric familiesall 38 queries against real data 0 PromQL errorsdrain triggered, annotation query re-run 1 series, 2 samplesserver restarted, resets() annotation re-run 1 series, 4 detectionsserver stopped, alert state polled CyberChefMCPDown -> pendinghelm lint + every optional render path clean; guards fire correctlyThe dashboard uses a state timeline for the lifecycle, a bucket heatmap with exemplars for latency, a four-query joined table with gauge and colour-background cells, a horizontal bar chart, threshold-dashed limit overlays, and annotation queries that mark restarts and drains on every graph. Panel options were taken from Grafana’s own schema files rather than from memory.
It is schema v1, deliberately. Grafana 12’s v2 dynamic-dashboard schema would suit it, but it is experimental, Grafana advises against production use, and migration is one-way. An artefact users import into their own Grafana is not the place for that.
Latency has no alert, and that is a decision. A base64 decode and a bcrypt comparison are both “one operation”; any single threshold is useless for the fast ones or a permanent page for the slow ones. That is also why the dashboard leads with a heatmap: the distribution is genuinely multi-modal, and a p95 averages three populations into a number describing none of them.
Environment variables
Section titled “Environment variables”| Variable | Default | Meaning |
|---|---|---|
CYBERCHEF_METRICS_ENABLED |
false |
Serve /metrics. Unauthenticated when on. |
Upgrading
Section titled “Upgrading”Nothing changes unless you opt in. With CYBERCHEF_METRICS_ENABLED unset, /metrics 404s and
the OpenTelemetry instrumentation is a measured no-op. stdio is untouched.
One behaviour changed without a flag: TelemetryCollector now maintains per-tool counters
regardless of CYBERCHEF_TELEMETRY_ENABLED. They are counts of tool invocations — no arguments, no
durations, no timestamps — held in memory and readable only through /metrics, which is itself off
by default. The per-call records that flag governs remain opt-in and unchanged.
What review found
Section titled “What review found”The PR review caught five more, four of them mine and one a genuine gap in a fix I had already made:
- The cardinality cap was exhaustible. Capping distinct tool labels bounds the count but not who gets the slots — an attacker filling all 1024 before real traffic makes legitimate tools collapse into the overflow bucket, degrading exactly the metrics the cap was protecting. Names are now resolved against the real dispatch catalogue first, so an unknown one never occupies a slot.
- The OpenTelemetry span dimension had no bound at all. I had fixed the Prometheus labels and left the span name and histogram attributes taking the caller-supplied name verbatim — the characteristic shape of a partial mitigation, applied where the bug was observed rather than where the untrusted value enters.
/metricssat in front of the DNS-rebinding check. I copied the health probes’ placement along with their justification without checking it still held: the probes may skip theHostallowlist because they disclose nothing, and a scrape discloses a great deal.undefinedcould reach the exposition body, which fails the entire scrape.- Alerts mixed every deployment together. Fixed by aggregating
by (job)— and the test I wrote to pin the general property then caught one more rule the review had not named.
Verification
Section titled “Verification”npm run lint cleannpm run test:mcp 1321 passed (50 files)npm run test:coverage 95.82% stmts · 90.12% branches · 96.64% funcs · 96.65% lines — gate passes src/node/lib/** held separately against a 99 / 94 / 100 floorcold start 147-164 ms over 6 runs (v2.6.0 baseline: ~185 ms)Twelve properties verified by mutation rather than assumed: restoring the duplicate-HELP loop,
removing the cardinality cap, moving the counters back behind the telemetry gate, drifting a chart
alert threshold, drifting an alert for duration, renaming a server metric, and overlapping two
dashboard panels each fail the tests that cover them — as do removing the /metrics host check,
reverting the tool dimension to a bare cap, un-ending the probe span, swapping the label-escaping
order, and dropping the sample-value coercion.