Test your own MCP server
You built (or are about to add) an MCP server. Before an agent connects to it,
check it the same way you would run npm audit: scan the tool metadata for
poisoning and over-broad permissions, then optionally front it with a gateway
that logs and enforces an allowlist.
Table of contents
1. Install
pipx install shield-mcp # Python CLI
# or, no Python toolchain:
npx @votal/mcp-scan --help # thin wrapper over the same engine
2. Point it at your server
The scanner needs a target describing how to reach your server. Use the same command or URL your MCP client (Claude Desktop, Cursor, your agent) would use.
| Your server is started by | Target to pass |
|---|---|
npx -y my-mcp-server |
stdio:'npx -y my-mcp-server' |
python -m my_server |
stdio:'python -m my_server' |
node dist/server.js |
stdio:'node dist/server.js' |
uvx my-mcp-server |
stdio:'uvx my-mcp-server' |
| an SSE endpoint | sse:https://your-host/sse |
| a streamable-HTTP endpoint | http:https://your-host/mcp |
Include any arguments exactly as your client passes them, for example a path or a
config flag: stdio:'npx -y @modelcontextprotocol/server-filesystem /data'.
Tip: the fields you would put in a
claude_desktop_config.jsonentry (command+args) are exactly what goes afterstdio:.
3. Scan it
shield-mcp scan "stdio:python -m my_server"
You get a report and an exit code:
MCP scan: stdio:python -m my_server [stdio]
tools=6 resources=0 prompts=0
[CRIT] tool-poisoning: tool 'summarize'
Description contains text that instructs the model reading it.
evidence: ignore all previous instructions
verdict: FAIL (fail-on: critical)
| exit code | meaning |
|---|---|
| 0 | clean (safe to connect) |
| 2 | findings at or above --fail-on (read them before connecting) |
| 3 | could not reach or start your server |
| 4 | bad target string |
Useful flags: --json (machine-readable), --fail-on high (stricter),
--timeout 30.
4. Read and fix the findings
| category | what it means | how to fix |
|---|---|---|
tool-poisoning |
a description tells the model to do something (ignore instructions, exfiltrate, read secrets) | remove the instruction text from the tool/param/resource description |
encoded-content |
the same, hidden under base64/hex/ROT13 | remove the encoded blob |
over-broad-permission |
a tool exposes exec/shell/delete/wire_transfer-style power |
narrow the tool, or gate it behind a role (step 6) |
suspicious-metadata |
zero-width/bidi characters, HTML comments, or a giant description | clean up the description |
shadow-capability |
a tool has no description | add one so it can be audited |
Re-run the scan until verdict: PASS. If a finding is a deliberate,
well-understood capability (say an admin exec tool), you can raise the gate with
--fail-on critical (the default) so only poisoning fails CI, and govern the tool
at runtime in step 6.
5. Gate every change in CI
Fail a pull request that introduces a poisoned or over-broad tool. Add
.github/workflows/mcp-audit.yml:
name: mcp-audit
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: votal/mcp-scan-action@v1
with:
target: "stdio:python -m my_server"
fail-on: high
Or a local pre-commit hook:
#!/usr/bin/env bash
shield-mcp scan "stdio:python -m my_server" --fail-on high \
|| { echo "MCP scan failed. See findings above."; exit 1; }
6. Govern it at runtime (optional)
Scanning is a pre-flight check. To also log every tool call and enforce a
role-to-tool allowlist while your server runs, put it behind the
small-team gateway. A minimal gateway.yaml:
team: my-startup
log: { format: json, path: "-" }
routes:
- route: mine
transport: stdio
command: python
args: ["-m", "my_server"]
scan_descriptions: true # annotate poisoned descriptions on tools/list
isolation_ack: true
rbac:
roles:
reader: { allowed_tools: ["search", "read_file"] } # everything else is blocked
agents:
my-agent: reader
docker run -p 8080:8080 \
-v $PWD/gateway.yaml:/etc/mcp-gateway/gateway.yaml \
shield-mcp-gateway
Point your agent at http://localhost:8080/gateway/mine/mcp with
X-Agent-Key: my-agent and X-User-Role: reader. A call outside the allowlist is
blocked, and every decision is logged as JSON.
7. Score it against others (optional)
Curious how your server compares? The MCP registry scores public
servers 0 to 100 from these same scans. To add yours, open a PR against
registry/servers.yaml with your slug, name, and target.
Full CLI reference: MCP scanner. Runtime governance: small-team gateway.