DEV Community

Royal Simpson Pinto
Royal Simpson Pinto

Posted on

I built a security linter for MCP servers, because nobody audits the tools we hand our agents

We spent years learning to distrust the code we ship. We run npm audit, we run linters, we gate pull requests on static analysis. Then the Model Context Protocol arrived, we started handing language models real capabilities, and most of that discipline quietly evaporated.

An MCP server is not a passive data source. It gives a model the ability to run commands, read files, hit internal URLs, and mutate databases. A single over-scoped tool, or a .env file exposed as a resource, turns a helpful agent into a remote-code-execution or data-exfiltration path. Yet most MCP servers ship with no security review at all. There is no npm audit for the surface you are about to attach to your agent.

So I wrote one. It is called mcp-audit.

The core idea

mcp-audit treats an MCP server the way a linter treats a source file. It connects to the server (or reads a manifest describing it), enumerates every tool, resource, and prompt the server advertises, and runs a catalog of security rules over that surface. Then it reports findings with a stable id, a severity, a location, and a concrete remediation.

The design goals were narrow on purpose:

  • It runs offline and is fully deterministic. No model calls, no network heuristics, same input gives the same output.
  • It drops into CI. There is JSON output and SARIF 2.1.0 output so findings show up as annotations in GitHub code scanning.
  • It fails the build when it should. The process exits non-zero once any finding reaches a configurable severity threshold.

How it works

The fastest way to use it is to point it at a server you spawn over stdio:

npx @royalpinto007/mcp-audit stdio "node my-mcp-server.js"
Enter fullscreen mode Exit fullscreen mode

It launches the server, speaks the MCP handshake, asks it to list its tools and resources, and audits what comes back. There are three other entry points:

# Audit a remote server over HTTP, with a bearer token
npx @royalpinto007/mcp-audit http https://mcp.example.com/mcp --token "$MCP_TOKEN"

# Lint a server's declared surface from a manifest, without running it
npx @royalpinto007/mcp-audit static ./mcp-manifest.json

# List every built-in rule
npx @royalpinto007/mcp-audit rules
Enter fullscreen mode Exit fullscreen mode

That static mode matters more than it looks. It lints a JSON manifest of the tools, resources, and prompts a server would advertise, without ever executing the server. That is exactly what you want when the server is untrusted and you are reviewing it before it ever runs on your machine.

Right now there are 18 built-in rules across categories like permissions, schema, injection, secrets, transport, metadata, and hygiene. Each one is an independent module with a stable MCPxxx id. To make this concrete, here is roughly what the destructive-tool rule (MCP001) does:

// MCP001 - a tool that implies deletion but exposes no confirmation argument
const haystack = `${tool.name} ${tool.description ?? ""}`;
const hit = containsAny(haystack, DESTRUCTIVE_VERBS);
if (!hit) continue;

const props = schemaProperties(tool).map((p) => p.toLowerCase());
const hasConfirm = props.some((p) =>
  CONFIRM_HINTS.some((c) => p.includes(c)),
);

if (!hasConfirm) {
  // report: destructive action with no confirmation/scope parameter
}
Enter fullscreen mode Exit fullscreen mode

The other rules follow the same shape. MCP002 flags tools whose name or description implies arbitrary command or shell execution. MCP030 flags resources that expose secrets or sensitive paths, like a .env handed out as a readable resource. MCP040 flags HTTP transports with no authentication. MCP041 flags tools that take a caller-controlled URL, which is the classic SSRF setup. There are also schema rules for missing input schemas and unconstrained string arguments, an injection rule for prompt-injection text planted in descriptions, and hygiene rules for things like duplicate tool names.

A run looks like this:

 CRITICAL   (3)
  MCP002 Arbitrary execution tool detected  @ run_shell
      Tool "run_shell" appears to execute commands or code (matched "shell").
      fix: Avoid exposing raw exec. If unavoidable, allowlist commands,
           drop privileges, sandbox execution, and require human approval.
  MCP030 Resource surfaces sensitive material  @ file:///home/app/.env
Enter fullscreen mode Exit fullscreen mode

Everything is configurable through a .mcpauditrc file that mcp-audit discovers by walking up from the working directory. You can disable rules, run only a specific set, remap a rule's severity, ignore findings by location substring, and set the fail-on threshold. The CI recipe is one line plus the SARIF upload:

npx @royalpinto007/mcp-audit static ./mcp-manifest.json \
  --sarif --output mcp-audit.sarif --fail-on critical
Enter fullscreen mode Exit fullscreen mode

The whole thing is TypeScript, and the test suite has 47 tests, including a real mock MCP server as a fixture so the tests exercise the actual stdio transport rather than a stub.

One honest limitation

These rules are static and, for the most part, lexical. MCP001 fires because a tool's name or description contains a destructive verb and its schema has no confirmation-shaped parameter. MCP002 fires because a description matches an execution term. That means the checks are deterministic and fast, but they reason about how a tool describes itself, not about what its implementation actually does.

A tool honestly named delete_everything gets caught. A tool blandly named process_item that quietly shells out will not be flagged by the name-and-schema heuristics, because mcp-audit never sees the server's source. It audits the advertised surface. This is genuinely useful, since the advertised surface is exactly what the model gets to see and act on, and it catches a large class of real mistakes. But it is a surface linter, not a proof of safety. Treat a clean report as "no obvious footguns in the declared interface," not "this server is secure."

Try it

If you are building or adopting MCP servers, run it against one before your agent does:

npx @royalpinto007/mcp-audit stdio "node my-mcp-server.js"
Enter fullscreen mode Exit fullscreen mode

Code and full rule catalog are here: https://github.com/royalpinto007/mcp-audit

Package on npm: https://www.npmjs.com/package/@royalpinto007/mcp-audit

New rules are the most useful contribution. A good one is independently coded, has a stable id and clear remediation, and ships with a test that fires it against a fixture plus one that proves it stays quiet on a clean surface. If there is a class of MCP footgun you keep seeing, that is a rule worth adding.

Top comments (0)