Field note · August 8, 2026

The `npx -y` in your MCP config is a standing order

7 min readMCP · SECURITY · SUPPLY-CHAIN · AI · NPM

Open your Claude Desktop config, or your .mcp.json, or Cursor's equivalent. Mine had a handful of entries, and most of them contained a line like this:

"args": ["-y", "some-mcp-server"]

I put each of those lines there myself, following some README, and I had not thought carefully about what the line says. It says: every time my client starts, resolve whatever version of this package the npm registry serves at that moment, download it if needed, and execute it. Inside a process that holds my API tokens. Wired directly into my model's context. Not once, after review. Every session, forever, unattended.

A dependency in package.json gets a lockfile, a review when it changes, and a CI run before it ships. The servers in an MCP config get none of that. There is no lockfile for your agent's tool chain. That asymmetry seemed worth a tool.

The audit

npx -y @miaggy/mcp-audit@0.3.0 audit reads the MCP configuration on your machine (Claude Desktop, Claude Code, Cursor, VS Code, and Goose), checks each configured npm or PyPI package against its registry, and prints findings with a severity, a concrete threat, and a fix. Five checks in the static layer:

  • Unpinned versions. No @version, a dist-tag like @latest, or a range all count as unpinned, because they all mean the registry decides what runs next session.
  • Credentials in env blocks. Literal API keys pasted into config files that sync to backups and dotfile repos. The finding names the variable and never prints the value.
  • No provenance. Packages published without an attestation binding the tarball to a source repository and build, which leaves you trusting whoever holds the npm token.
  • Install scripts. preinstall and postinstall hooks run arbitrary code at install time, before the server ever starts.
  • Maintenance signals. Single maintainer, no publish in 18 months: the profile of the easiest accounts to take over.

This layer is static. It parses files and queries the registry; it never executes anything it finds.

The first real run was against my own machine. It found an unpinned server, a package published without provenance, and one that runs install scripts, then exited 1. I wrote the rules, and the rules encode the exact guidance this project ships in its own README, which is why the command above pins its version. The tool audits its own install instructions, and on day one it audited its author.

The layer nobody reads

A tool description is text an MCP server hands your client, and your client hands your model, silently, on every session. You see a tool named lookup_weather in a menu. Your model sees the full description, which can say anything, including instructions. Nobody reads these. I had never read mine.

So the second layer reads them for you. It is opt-in (--manifests), because it does something the static layer refuses to do: it starts each configured server, using a handshake-only MCP client that sends initialize and tools/list, never a tool call, and then shuts the server down. The line between the two layers is drawn precisely, and the absence of any tool-invocation API in the package is checkable with grep.

Four checks run over what comes back: prompt-injection language in tool descriptions (the same signature families the engine uses elsewhere), the same tool name exposed by two different servers (calls are routed by name, and whichever server wins receives the arguments meant for the other), destructive-sounding tools that declare no safety annotations, and descriptions thousands of characters long, which is where smuggled instructions hide.

My own machine again: six tools with oversized descriptions and three destructive-sounding tools with no annotations, across servers I use daily and had never once inspected at this layer.

The rug pull

The scariest shape in this space is the update that changes nothing visible. Same server name, same tool names, and one description quietly rewritten in a patch release. Your client shows you nothing, because from the menu's point of view nothing changed.

The third layer exists for that. mcp-audit snapshot records a baseline: which servers, which launch specs, which tool names, and a hash over the canonical name-and-description pairs. Descriptions are hashed, never stored, and env variables appear as names only, so the baseline file is safe to keep and even commit. Later, audit --baseline diffs. A changed description with identical tool names surfaces as a high-severity finding. A server that appeared since the snapshot surfaces as its own finding. Accepting an expected change is one fresh snapshot. The format is documented and version-gated, so a future reader refuses politely instead of guessing.

Where it stops, and says so

I hold one design rule above the others, carried over from the first scanner I shipped: a security tool that reports clean while blind is worse than no tool. Every server the audit discovers now appears in the report, assessed or not. A server launched with uvx, docker, node, or a bare path; a url server with no local command; a config file that will not parse; a registry lookup that failed because you were offline: each one is a named entry with a reason, not an empty result. An empty findings list means the audit looked and found nothing, never that it skipped a server.

That property is the point, so here is where the audit stops and tells you it stopped.

uvx and pipx servers are now checked against PyPI for pinning and maintenance. But PyPI publishes neither install-script data nor provenance, so those two checks cannot run for a Python package, and the report names the gap instead of implying a pass. Real coverage with a stated edge, not a silent skip.

Container images are out of scope on purpose. A docker or podman server is discovered and named, but the image itself is not inspected. Doing that means pulling layers and authenticating to a registry that may be private, which is a container scanner's job (Trivy, Grype), and not something a read-only auditor should hold credentials for.

Remote servers declared with a url are named, but their tools are only read if you point the opt-in manifest scan at a local stdio server. A hosted endpoint gets audited at its source, not here.

Five clients are read fully: Claude Desktop, Claude Code including its project-scoped servers, Cursor, VS Code across its variants, and Goose. Windsurf, Cline, Continue, and Zed are read on a best-effort basis, because their config formats change between versions; an unrecognized shape finds zero servers rather than erroring.

Injection detection is signature-based, a handful of regex families, so treat a hit as a pointer to text worth reading, not a verdict.

The engine underneath

This is the second scanner on a shared engine, and that is the part I am quietly proudest of. The first was bedrock-security-mcp, an AWS Bedrock posture auditor. Extracting its finding schema, rule registry, scoring, and report renderers into a core package was done under a strict contract: golden-file tests pinned the old tool's output byte-for-byte before any code moved, and the rebuilt version had to reproduce it exactly. It did. The result is that this second scanner, collectors and rules and fixtures and all, took days rather than months, and both tools emit the same finding schema, the same 0-to-100 posture score, the same markdown and single-file HTML reports, and the same CI gate: exit 1 on any critical or high finding, so a pipeline can refuse to proceed.

Everything is verifiable rather than asserted: 248 tests across the workspace, including a deliberately malicious MCP server that lives in the repo and is spawned over real stdio in the integration suite to prove every manifest rule fires. Releases are published from GitHub Actions with npm provenance and no tokens anywhere; npm audit signatures checks the attestation on your machine.

Run it

npx -y @miaggy/mcp-audit@0.3.0 audit                 # static, five seconds
npx -y @miaggy/mcp-audit@0.3.0 audit --manifests      # plus the handshake scan
npx -y @miaggy/mcp-audit@0.3.0 snapshot               # record the baseline
npx -y @miaggy/mcp-audit@0.3.0 audit --baseline mcp-audit-baseline.json

Or add it to your client as an MCP server, pinned, and ask your model to audit its own tool chain.

Every entry in your MCP config is a standing order you signed. The order stays in force until you revoke it, and until last week I could not have told you what half of mine actually said. Now the audit takes five seconds, so I have stopped guessing.


Repo: AUAggy/agent-sec-scanners

The project, opinions, and mistakes here are mine. AI tools helped with drafting and editing; every technical claim was checked against real output before publishing.

← Back to journal