GUESTBOOK.EXEcontact.exe
MCPAUDIT

</mcpaudit>

MCPAUDIT

Lints any MCP server locally, and every finding says how the check misfires.

0

runtime dependencies in the published package

17

checks, every one documenting how it misfires

4 / 4

official MCP reference servers still on a superseded protocol revision

SPECIFICATIONS

ROLESOLO BUILD
YEAR2026
TYPECLI TOOL
STATUSPUBLISHED
STACKtypescript · node · cli · mcp · sarif · vitest · github actions · zero-dependency
LINKS[live ↗][github ↗]
AVAILABILITYnpm · @aethereumdev/mcp-audit

MCP servers hand a language model a list of tools, and the model follows their descriptions as instructions.

mcpaudit points at any Model Context Protocol server — over stdio or Streamable HTTP — and returns a report on what is broken and what is dangerous.

README.TXT: MCPAUDIT (12 KB)[full readme →]

== WHAT IS THIS ==

────────────────────────────────────────

mcpaudit points at any Model Context Protocol server — over stdio or Streamable HTTP — and returns a report on what is broken and what is dangerous. Nine conformance checks read the server against the specification, seven safety heuristics look for tool poisoning, credential exposure in schemas, off-host schema $refs and terminal-escape injection, and a drift detector diffs a server against a baseline you pinned earlier, so a tool description rewritten after you approved it becomes a finding rather than a surprise. It runs entirely on your machine with no API key and no telemetry, ships zero runtime dependencies, and emits terminal, JSON or SARIF output with CI exit codes. Published on npm as @aethereumdev/mcp-audit under MIT.

== </the problem> ==

────────────────────────────────────────

MCP servers hand a language model a list of tools, and the model follows their descriptions as instructions. That makes a tool description an untrusted input with unusual reach: it can contradict its own safety annotations, hide characters, name another server's tools, carry terminal escapes into whatever renders the report, or simply be rewritten after you approved it with no version bump and nothing changing on your side. Meanwhile the specification moved out from under the ecosystem — revision 2026-07-28 removed the initialize handshake — so "does this server follow the spec?" stopped having one answer. There was no single local command that would answer both questions about a server before you connected an agent to it.

role & context

Solo build — specification research, both transports, all seventeen checks, the report formats, the fixture suite and the release rails. The four reference-server audits under audits/ were run with the tool at v0.1.0 and every finding in them was hand-reviewed before the README table was written.

== </my approach> ==

────────────────────────────────────────

Two lanes behind one command, with era resolution in front of both. Point mcpaudit at a command (stdio) or a URL (Streamable HTTP); it probes server/discover, falls back to an initialize handshake, and settles which specification era the server belongs to before a single check runs. Lane A asks whether the server implements the spec correctly, Lane B asks whether it is dangerous, and a separate drift detector answers "has this changed since I approved it?" against a baseline you pinned. Every rule carries its own documented false-positive modes as a required field, and RULES.md is generated from that metadata so the docs cannot drift from the code. Output is terminal, JSON or SARIF, with exit codes designed for CI.

== </the story> ==

────────────────────────────────────────

MCP servers hand a language model a list of tools, and the model treats their descriptions as instructions. That is a lot of reach for a string you did not write, fetched from a server you did not audit — and those descriptions can be rewritten after you have approved them, with no version bump and nothing changing on your machine. At the same time the specification moved: revision 2026-07-28 removed the initialize handshake and made server/discover mandatory. mcpaudit is the command that runs before you connect an agent to a server: point it at a command or a URL, and it reports what breaks the spec and what looks dangerous, locally, with nothing installed alongside it.

The first real finding was about the ecosystem rather than any one server. Four official reference servers were audited with the tool at v0.1.0 and the raw --json for each was committed to the repo — all four answer server/discover with an error, complete an initialize handshake, and report protocol 2025-11-25. Nothing ships that speaks the current revision. A conformance suite written straight against 2026-07-28 would have produced a page of red against every real server on earth, which is why era resolution was pulled in front of the entire rule registry: checks that do not apply to a server's era are skipped with a stated reason, never failed. Across those four audits that is four checks skipped per run, and the difference between two findings per server and unusable noise.

The framing was fixed before the first check was written, and it is load-bearing: a first-pass linter that catches common issues, not a security audit. It reads what a server says about itself and applies documented heuristics — it does not sandbox, does not read source, and does not prove anything. That framing is enforced structurally rather than by discipline: falsePositiveModes is a required field on every rule and on every finding the tool emits, so a check that cannot state how it misfires cannot compile. The nine findings across the four reference servers were all hygiene and spec-currency observations, none at error severity, and none needed a disclosure.

== </architecture> ==

────────────────────────────────────────

A target is either a command or a URL. Commands run over stdio, spawned with shell:false through a hand-written tokenizer so a crafted target string cannot smuggle shell metacharacters; URLs run over Streamable HTTP. Both feed one client that never throws — a hostile server is data, so every request resolves to one of six named outcomes (result, error, timeout, transport-error, invalid-json, invalid-envelope) under caps on line size, body size and stderr. A server that emits garbage, hangs, or dies mid-conversation produces a finding instead of a stack trace.

Era detection sits between the transport and the rules. server/discover is attempted first; a -32601 sends the client down the legacy initialize path. The resolved era is then matched against each rule's declared appliesTo list, and a mismatch produces a skip with a reason recorded in the report — the engine does this before any rule body executes, so an inapplicable check cannot fire at all.

The rule registry holds nine conformance checks (C0–C8) and seven safety checks (S1–S7); surface drift lives outside it because it needs a caller-supplied baseline, which is why src/rules/ holds seven files while the documentation lists eight safety entries. Every rule is self-describing: its metadata — severity, applicable eras, spec reference, CWE, false-positive modes, remediation — lives beside its logic, is copied onto every finding it emits, and is the sole source RULES.md is rendered from. One AuditReport fans out to three renderers: a styled terminal report that respects NO_COLOR and collapses to plain monospace off-TTY, --json, and SARIF 2.1.0. Findings are sanitised before display, because otherwise a server flagged for ANSI injection could inject ANSI into the report flagging it.

== </key features> ==

────────────────────────────────────────

Era-aware checks that skip, never fail

Every rule declares which specification eras it applies to. Against a legacy server, four modern-protocol checks are skipped with a stated reason recorded in the report rather than failed — visible in every committed audit under audits/.

Rug-pull detection via a pinned baseline

--pin writes a canonicalised snapshot of the tool surface; --baseline diffs against it. Description and schema changes are errors because they alter what the model is told, and an annotation change that weakens a safety claim is called out separately from an ordinary release edit.

Seven safety heuristics on the tool surface

Tool poisoning and hidden characters, destructive verbs contradicting readOnly annotations, credential material in schemas and defaults, off-host or expensive schema $refs, cross-server name shadowing, ANSI and control sequences in rendered text, and unsafe icon URI schemes.

Every finding ships its own false-positive modes

falsePositiveModes is a required field on the finding type, not an optional one, so the JSON report tells you how the check that fired can be wrong. RULES.md is generated from the same metadata and CI fails when the committed copy goes stale.

SARIF output and CI exit codes

--sarif emits SARIF 2.1.0 for GitHub code scanning and --fail-on sets the severity threshold. Exit 0 clean, 1 for findings at or above the threshold, 2 for a server that could not be talked to at all.

A transport that treats hostility as data

Caps on line size, body size and stderr, stdio spawned with shell:false through a hand-written tokenizer, and six named outcomes instead of exceptions. The hostile fixture serves a 2000-tool list, a 400-deep schema, a 500-notification flood, truncated JSON and a server that exits mid-conversation.

== </key decisions> ==

────────────────────────────────────────

DECISION 01

Zero runtime dependencies, enforced by a test rather than asserted in the README. A tool that inspects other people's supply chains has to survive the same question about its own, so the arg parser, the type validators and the SSE parser are hand-written and fetch is the Node built-in. The price is stated openly in the docs instead of buried: inputSchema validation is structural — parseable, an object, type "object" at the root, bounded in depth and $ref count — not full JSON Schema 2020-12 meta-validation, which would need a validator dependency. A schema can pass this check and still be rejected by a strict validator.

DECISION 02

Era resolution in front of the rule registry, rather than a flat conformance suite. Research found no shipping server implementing 2026-07-28, and the four reference-server audits confirmed it — all legacy, all 2025-11-25. Firing modern checks at legacy servers would produce a wall of false failures and spend the tool's credibility on contact. Rules declare their eras; the engine skips the rest with a reason. An inapplicable check is never a failure.

DECISION 03

Linter framing, never "security audit" — and made structural rather than aspirational. falsePositiveModes is a required field on RuleMeta and on the Finding type, so the JSON report tells you how the check that just fired can be wrong, and a check that cannot state its failure modes cannot be written. RULES.md is generated from that same metadata and CI fails when the committed copy is stale, so the documentation cannot drift from the code. Overclaiming security in public is how a tool like this gets taken apart by people who do it for a living.

DECISION 04

A transport that treats hostility as data instead of exceptions. The servers most worth auditing are the ones least likely to behave, so every failure mode is a named outcome under explicit size caps. The hostile fixture — a 2000-tool list, a 400-deep schema, a 500-notification flood, garbage on stdout, truncated JSON, a frame with no trailing newline, a server that says nothing at all, and a server that exits mid-conversation — passed on the first run, which is the payoff for that decision rather than a coincidence.

DECISION 05

Pack the tarball and run it in CI, not just the tests. The packed CLI produced no output at all under npx while every unit test passed, because npm installs bin as a symlink and the entry-point guard compared import.meta.url to process.argv[1]. The lesson generalised into a pipeline step: npm pack, install into a clean directory, run --help and --version. Testing the source is not testing the artifact.

== </what i learned> ==

────────────────────────────────────────
>

A green test suite is evidence about the code you tested, not the artifact you ship. The CLI produced no output at all under npx while every unit test passed, because npm installs bin as a symlink and the entry-point guard compared paths. Packing the tarball and running it is now a CI step.

>

A fixture can be wrong in a way that makes a test vacuous. The broken fixture built to trip the determinism check had symmetrical tool names, so reversing their order changed nothing — the test passed by never actually testing anything.

>

Hand-reviewing output before publishing it is not optional. Four error-severity false positives on real reference servers came from "clearly" matching the destructive verb "clear"; nothing in the suite would have caught it, because the suite agreed with the bug.

>

Design for the ecosystem that exists, not the one the spec describes. Every shipping server is a revision behind, so the choice was between failing all of them or making inapplicable checks skip with a reason. Credibility is spent on the first run.

>

Underclaim on purpose. Framing this as a first-pass linter and requiring every rule to document how it misfires is the weaker pitch and the defensible one — and it forced a better design, because a check that cannot state its failure modes never gets written.

next:

Resource and prompt coverage is the biggest gap — resource descriptions and prompt templates carry the same injection surface as tool descriptions and are not inspected at all today. After that: an env-dump rule for tools that return the whole environment, and the deferred token-passthrough and OAuth-metadata SSRF checks, which cannot be written honestly until a server exists that speaks the 2026-07-28 auth flow. The real test of the design is the first false positive someone else files against their own legitimate server: that is a bug in the check, and the rule that fired already ships the documentation of how it misfires.

typescript · node · cli · mcp · sarif · vitest · github actions · zero-dependency

BUILD.TXT: MCPAUDIT (5 KB)[the build record →]

== </what the audit found> ==

────────────────────────────────────────

The audit that shaped the tool was of the ecosystem, not the code. The current specification revision, 2026-07-28, removed the initialize handshake and made server/discover mandatory — and nothing ships that speaks it. Every one of the four official reference servers audited answers server/discover with an error and falls back to an initialize handshake at 2025-11-25; the committed audit JSON records the era as "legacy" for all four. A flat conformance suite built against the current spec would therefore have failed every real server on contact, which is a tool nobody installs twice. That single finding is why era resolution happens before any check runs.

== </what was repaired> ==

────────────────────────────────────────

Six defects, four of them hidden behind a green test suite. The worst: the packed CLI produced no output at all under npx, because the entry-point guard compared import.meta.url against process.argv[1] and npm installs bin as a symlink — every unit test passed throughout, and only running the packed tarball caught it, so npm pack → install into a clean directory → run is now a CI step rather than a pre-release ritual. A determinism check could never fire, because the deliberately-broken fixture's tool names were symmetrical and reversing their order was a no-op; a fourth tool broke the palindrome. S6 scanned raw wire bytes looking for an ESC character that always arrives JSON-escaped, so it was structurally incapable of matching; it now scans decoded text. Four false positives fired at error severity against real reference servers because the word "clearly" matched the destructive verb "clear" with no trailing word boundary — caught by hand-reviewing every finding before the README table was published, and fixed with a two-tier verb model where weak verbs (remove, reset, clear) only count in a tool's name and only beside a stateful object. The rug-pull detector hashed uncanonicalised JSON, so a server that merely reordered its keys would have looked like a deliberate downgrade on every re-audit. And the first version of the weekly spec-drift job was circular: it read the protocol version out of the versioned file, which always matches itself.

== </what was measured> ==

────────────────────────────────────────

Four official reference servers were audited with the raw --json committed under audits/, so the README table is checkable rather than summarised by hand. The totals re-derived from those files: nine findings across the four servers, at info, warn and low severity only — zero at error severity, and no exploitable vulnerability, so no private disclosure was needed. Era-awareness is visible in the same files: each run skips four checks with a stated reason ("applies to modern servers; this one is legacy") instead of failing them, which is the difference between two findings per server and a wall of noise. Calibration is reproducible offline in two commands from the repo — the malicious fixture returns 22 findings, the benign one returns zero — and the packed tarball installs into a clean directory with an empty dependency tree.

== </decisions> ==

────────────────────────────────────────

DECISION 01

Zero runtime dependencies, enforced by a test rather than asserted in the README

because A tool that inspects other people's supply chains has to survive the same question about its own, so the arg parser, the type validators and the SSE parser are hand-written and fetch is the Node built-in. The price is real and is stated in the docs rather than buried: inputSchema validation is structural — parseable, an object, type "object" at the root, bounded in depth and $ref count — not full JSON Schema 2020-12 meta-validation, which would need a validator dependency. A schema can pass this check and still be rejected by a strict validator.

DECISION 02

Era resolution in front of the rule registry, rather than a flat conformance suite

because No shipping server implements 2026-07-28, so a suite written straight against it would fail every real server. Each rule declares which eras it applies to and the engine resolves the era before running anything; an inapplicable check is skipped with a reason, never failed. A linter that cries wolf on its first run is uninstalled on its first run.

DECISION 03

Linter framing, never "security audit" — made structural instead of aspirational

because Overclaiming security invites public dismantling by people who do this professionally. falsePositiveModes is a non-optional field on both RuleMeta and every emitted Finding, so a check that cannot say how it misfires cannot ship, and the JSON report tells you how the check that just fired can be wrong. RULES.md is generated from that same metadata with CI failing on a stale copy, so the documentation cannot drift away from the code.

DECISION 04

A transport that treats hostility as data, not as exceptions

because The servers most worth auditing are the ones least likely to behave. Every request returns one of six named outcomes (result, error, timeout, transport-error, invalid-json, invalid-envelope) under caps on line size, body size and stderr, so a server that emits garbage, hangs, or dies mid-conversation produces a finding instead of a stack trace. The hostile fixture passed on the first run, which is the payoff for that decision rather than luck.

LIMITS.TXT: MCPAUDIT (1 KB)[where it doesn't work →]

== </where it doesn’t work> ==

────────────────────────────────────────

It reads what a server says about itself and applies heuristics. It does not execute tools in a sandbox, analyse server source, or prove anything — a clean report means "nothing common was found", not "this server is safe". Schema validation is structural rather than full JSON Schema 2020-12, and only tools are inspected: resource descriptions and prompt templates carry the same injection surface and are not covered at all. The modern-protocol lane has never met a real modern server, because none exists — C7_HTTP_HEADERS is exercised only against hand-written fixtures, and the token-passthrough and OAuth-metadata SSRF checks are deferred for the same reason. S5 cross-server shadowing needs two or more targets in a single run and is skipped otherwise. It is also brand new: published on 2026-08-15, with no downloads, no stars and no outside users, and 0.1.0 was published by hand so it carries no provenance attestation — `npm view @aethereumdev/mcp-audit dist.attestations` is empty, and an attestation cannot be added retroactively.

PUBLISHED · npmjs.com