mcp-audit

An auditor for MCP servers. It connects as a real MCP client and checks two things: what the server tells the model, and what its tools actually do when they are called inside a sandbox.

A person reads an MCP server's README before installing it. The model reads its tool descriptions on every turn, and does what they say. That second reading is where the attacks are. Annotations such as readOnlyHint are only claims, so mcp-audit calls every tool in a container with no network, traces every file and network syscall, and compares what happened with what the tool claimed.

View on GitHub What it checks

Plays the seven stages below in order, about 70 seconds. Scroll or press Esc to stop.

Language
Python 3.11 or newer, mypy --strict
Tests
65 passing with pytest; 4 of them need Docker
Dependencies
One: the official mcp SDK
Sandbox
Docker with --network none, and strace
Transports
stdio, in the sandbox or on the host, and streamable HTTP
Runs as
A CLI, and a composite GitHub Action
License
MIT
mcp-audit scan -- python examples/corpus/poisoned_add.py mcp-audit: starting the server on this machine (no --sandbox): its code runs here server calculator target python examples/corpus/poisoned_add.py surface 1 tool, 0 prompts, 0 resources HIGH injection.conceal tool add, description HIGH injection.secret-path tool add, description HIGH injection.exfil-param tool add, description

Real runs of mcp-audit 0.1.0 at commit c753d0b, output verbatim. The first two tabs scan servers from the repository's corpus, which were written to be caught and touch nothing real. The third is the official mcp-server-git 2026.10.10 in the sandbox. The last pins a small example server and checks its next version.

1Read everything the model reads

mcp-audit connects with the official MCP SDK's client, as Claude Desktop would, and takes a snapshot: the server's instructions, and every tool, prompt and resource. Then it scans every piece of text in it, not just the descriptions.

  • Titles, descriptions, and every string inside input and output schemas: property descriptions, defaults, enums, $defs.
  • The rules look for writing aimed at the model: orders to ignore earlier instructions, to keep something from the user, to read a credential file, to put private data in a parameter, or to change how another server's tools are used.
  • Unicode tag characters render as nothing at all. They are decoded and scanned like any other text. Bidirectional overrides and zero-width characters are reported too.
  • Without --sandbox, the server runs on your machine to be listed, and no tool is called.

Four poisoned servers from the corpus

    The texts are the corpus servers' own, from examples/corpus/; the highlights mark the sentence each finding quotes. Findings and severities are real mcp-audit scan output, without --sandbox. The corpus also has five honest servers; the calculator's twin, clean_calc.py, scans 0 high, 0 medium, 0 info.

    2List the tools again, and compare

    A tool can be harmless when a person approves it and change later. mcp-audit lists the tools a second time straight away, and, when it calls them, once more afterwards. It compares each tool's description, schemas and annotations with the first listing.

    • A changed tool is rug-pull.changed, high. The finding says whether the server announced it with tools/list_changed.
    • The new text is scanned like the old, so what the change says is reported too.
    • A tool that appears later is rug-pull.added, medium; one that goes away is info.

    A rug pull that waits for the first call can't be seen from the listing alone. Scanned without a sandbox, the corpus's rug_pull.py comes back with 0 high, 0 medium, 0 info.

    Two rug pulls, listing by listing

      Both servers are in the repository's corpus. flip_on_relist.py changes its description on the second tools/list, with no notice; rug_pull.py rewrites its own after its first call and announces it. The terminal lines are the start of each real report: the first scan ran without a sandbox, the second in it.

      3Call every tool, in a box with no network

      With --sandbox, the server runs in a throwaway Docker container and the MCP session runs over its stdin and stdout, as a desktop client would run a local server. Every tool is called once.

      • Arguments are made from each tool's input schema, steered by property names: paths point into a seeded /workspace with a few files and a small git repository. --calls supplies your own.
      • The image plants credential files, ~/.ssh/id_rsa, ~/.aws/credentials, ~/.netrc and others, each holding a canary string. No argument ever points at them.
      • Calling tools on the host is refused unless you pass --unsafe-host-calls.

      The git reference server, called tool by tool

      
                    
                    

        The docker run line is the one sandbox.py builds; the container name is random per run. The calls are from a real scan of mcp-server-git 2026.10.10 in the repository's mcp-audit-reference image. Four calls returned errors, such as a branch type the server doesn't know; an error is still a call, and its syscalls are still traced.

        4Trace every syscall, and pin it to a call

        The container's entry point starts the server under strace -f -ttt -y, logging opens, writes, renames, deletes, connects, sends and execs to a directory mounted from the host. Each line carries a timestamp, and mcp-audit records when each call started.

        • Each call owns the time from its start to the next call's start, which catches work a tool does after replying.
        • Writes are opens with write flags, mkdir, rename and the like. A mkdir that fails with EEXIST changes nothing and isn't one.
        • Deleting a file the run itself created is cleanup, not destruction.
        • Network is any connect or send to a non-loopback address, plus DNS. It fails with no network, but the attempt is recorded. docker diff at the end catches what the trace missed.

        From hundreds of syscalls to a few events

            Real traces kept with --keep-trace from two corpus servers, and the events and call times mcp-audit read from them. The excerpt shows the lines that became events with a neighbour on each side; the rest of each log is library loading and the event loop waking itself, which aren't events. Times are seconds from the first call's start.

            5Judge behaviour only against the claim

            Per the MCP spec, a tool with no annotations may write, destroy and reach the network. So writing a file is not a finding in itself. It is one when the tool said readOnlyHint: true.

            • High: a planted credential opened that the arguments never named, canary data in a result or on a socket, a read-only tool changing a file outside /tmp, or a closed-world tool trying to connect.
            • Medium: a non-destructive tool deleting a file that existed before the run, or a result with text aimed at the model.
            • Behaviour the annotations allow is info: dynamic.write, dynamic.network, dynamic.exec.

            What each tool claimed, did, and got

              Every row is a real sandbox scan. The first seven tools are from the corpus, four meant to be caught and three honest ones; git_status is from the official git server. Its finding is true: plain git status refreshes .git/index unless run with --no-optional-locks, which the corpus's honest git twin does.

              AcceptedAbove this line mcp-audit reads, lists, calls and traces, and a finding at --fail-on fails the scan. Below it, a person accepts a server's definitions by pinning them, and any later change fails until they do it again.

              6Pin what a person reviewed

              mcp-audit pin writes a lockfile with a hash of each tool and prompt over what the model and the client act on: name, title, description, schemas and annotations. mcp-audit verify fails when any of it differs.

              • The lockfile keeps each description, so a failure shows the diff, not just that something changed.
              • A changed annotation alone changes the hash.
              • Only a person runs pin again to accept a change. verify never updates the lockfile.

              The text rules don't flag this change: a scan of version 2 prints 0 high, 0 medium, 0 info. The pin catches it because it doesn't need to understand the new sentence, only to see that there is one.

              The weather example, pinned and then updated

              mcp-audit.lock.json

              
                            
              examples/pin/weather.py is the repository's; given v2 it reports version 2.0.0 with one more sentence in its description. The runs are real, in a scratch directory. The lockfile pin wrote there is byte for byte the one committed in examples/pin/.

              7Run it on every pull request

              The repository is also a GitHub Action. It installs mcp-audit, scans the server command, and with a lock input also verifies it against the lockfile. scan exits 1 when a finding reaches --fail-on, high by default.

              • The repository's own CI runs the action three ways: an honest pinned server must pass, and the poisoned calculator and the pinned server's v2 must both fail.
              • A sandbox job builds the images, runs the Docker tests and checks the corpus results still match docs/corpus.txt.
              • --json FILE writes the full report, including the snapshot of everything the server exposed.

              The workflow step, and CI on the repository

              - uses: ahmed-hashim-pro/mcp-audit@main
                with:
                  command: npx -y @modelcontextprotocol/server-everything
                  args: --fail-on high
                  lock: mcp-audit.lock.json   # optional

              CI run 38662588295 on c753d0b5 jobs, all passed

                The step is the README's. Job names, results and durations come from GitHub's API for that run, in whole seconds. The action job's three checks took 5 s, 4 s and 5 s.

                Measured, not assumed

                A corpus written to be caught, and fifteen real servers whose every medium or high finding was read and given a verdict by hand. The rules were written against the corpus, so its perfect score proves little; the held-out servers are the test that counts.

                Half the attacks need a call

                10 attacks in the corpus, and 5 honest servers that write, delete, connect or run git as annotated. Reading the listing alone catches 5; with the sandbox, all 10.

                Listing only5/10
                With calls10/10
                5 honest servers0 false alarms

                The five only a call catches: a rug pull after the first call, a read-only tool that writes, a formatter that reads ~/.ssh/id_rsa and connects out, a credential in a result, and an injection in a result. Re-run here, the table matched docs/corpus.txt exactly.

                The held-out servers humbled it

                8 popular third-party servers the rules were not tuned on, such as playwright, notion and kubernetes. The first run's medium and high findings:

                First run29
                True4
                After fixes4 of 4

                The 25 false alarms came from three bugs: an EEXIST mkdir counted as a write (20), .aws matched inside docs.aws.amazon.com (3), and "Do not include API keys" read as a request for them (2). All three are fixed with regression tests, so the last row is no longer held out.

                Real findings, run again here

                All 15 servers, 7 reference and 8 held-out, scanned again on this machine with the repository's pinned images: 181 tools called, in 2 min 57 s.

                7 high findings6 match the verdicts1 new

                The six are the README's: the git server's read-only git_status rewrites .git/index, and markitdown opens ~/.netrc on every fetch, because Python's requests does. The new one is chrome-devtools' background npm check writing a log in ~/.npm/_logs while a read-only tool was in flight. It came up in 3 of 4 runs of that server here and has no verdict yet, so realworld.py --check failed on this machine.

                65 tests, four of them in Docker

                Counted with pytest -q -rs after make images: 65 passed. Without the images, as in CI's test jobs on Python 3.11, 3.12 and 3.13, 61 pass and the 4 sandbox tests skip; CI's sandbox job runs those 4.

                  Real servers, real clients, real containers

                  • Static rules: each rule on its own, every place text can hide in a snapshot, ordinary descriptions and scripts that use joiners and direction marks staying quiet, and a property test that any ASCII hidden in tag characters is recovered exactly.
                  • Regressions from the held-out run: .aws inside a host name, an instruction not to include secrets, and a mkdir of a folder that already exists.
                  • Judging: events pinned to the call in flight, late events, cleanup versus destruction, network only against a closed-world claim, startup activity, and changes only docker diff saw.
                  • Live: real stdio servers from the corpus, consent for host calls, --calls, a server that won't start, and streamable HTTP.
                  • Sandbox: the strace parser, a check that the launcher traces exactly the syscalls the parser reads, and, in Docker, the corpus's read-only writer and exfiltrator caught, the honest servers clean and no container left behind.

                  CI also runs ruff and mypy --strict, and fails if the corpus scores differently from docs/corpus.txt.

                  Deliberately missing

                  Attacks one call doesn't trigger
                  Each tool is called once with made-up arguments. A tool that misbehaves only for certain inputs, on the tenth call or on a date will pass. On the real servers most calls returned errors, so their dynamic coverage is thin.
                  Understanding
                  The text rules are patterns. A paraphrase that avoids every one, in English or another language, gets through, as the pin example's v2 does.
                  Your machine
                  The sandbox runs as root, in a fixed layout, with no network. A server that behaves differently online, or when it sees a container, may hide what it would do for you.
                  Remote servers
                  --url gets the listing checks and the relisting only. Its process isn't traced.
                  A code review
                  It reports what a server showed and did during the run, not whether its code is safe.

                  Run it

                  git clone https://github.com/ahmed-hashim-pro/mcp-audit
                  cd mcp-audit
                  python3 -m venv .venv && . .venv/bin/activate
                  pip install -e '.[dev]'
                  
                  # What a server tells the model (runs it here):
                  mcp-audit scan -- python examples/corpus/poisoned_add.py
                  
                  # What its tools do, in the sandbox:
                  make images
                  mcp-audit scan --sandbox mcp-audit-corpus \
                    -- python /corpus/exfil_network.py
                  
                  pytest -q           # 65 tests with the images

                  Python 3.11 or newer, and Docker for the sandbox. For your own server, build an image FROM mcp-audit-sandbox with it installed, and pass that image to --sandbox.