A static security scanner for MCP (Model Context Protocol) server source code. It reads a Python MCP server's source and reports concrete, file:line vulnerability findings: command injection, path traversal, insecure deserialization, hardcoded secrets, over-broad tool capabilities, and tool description poisoning (hidden prompt-injection payloads smuggled into the metadata an LLM trusts by default, including patterns aimed specifically at manipulating the calling agent: fake role/system framing, base64/hex-encoded payloads, homoglyph substitution).
It can also scan a whole MCP client config (scan-config) at once
instead of one server at a time, to catch risk that only shows up when
several servers run together. One server with broad data access plus a
separately configured server with network egress is a potential
exfiltration path even when each server looks clean on its own.
MCP servers are the fastest-growing, least-audited attack surface in AI tooling right now. Public research in 2026 found roughly 43% of public MCP servers vulnerable to command injection and 82% with path-traversal-prone file handling, and there still isn't a certification or vetting system for the hundreds of servers sitting in public registries. A proposed "MCP-38" threat taxonomy also catalogs attack classes with no direct analogue in traditional SAST tooling (tool description poisoning, indirect prompt injection, parasitic tool chaining) because they don't target the code. They target the model reading the code's metadata.
Guardrail-MCP is aimed at that gap. It understands MCP-specific constructs
(@mcp.tool(), @server.call_tool(), Tool(description=...)) well enough
to reason about them directly, instead of treating an MCP server as generic
Python.
uv tool install guardrail-mcp # or: pip install guardrail-mcp, once publishedFrom source:
git clone https://github.com/code-saksham-hash/Guardrail-MCP.git
cd Guardrail-MCP
uv sync
uv run guardrail-mcp scan path/to/server.pyguardrail-mcp scan path/to/server_or_directory [more paths...]
--format {terminal,json,html} # default: terminal
--output FILE # write report to a file instead of stdout
--min-severity {INFO,LOW,MEDIUM,HIGH,CRITICAL}
--fail-on {INFO,LOW,MEDIUM,HIGH,CRITICAL} # nonzero exit code, for CI
--verify # dynamically confirm GMCP-CMD001 findings (requires Docker)
guardrail-mcp scan-config path/to/claude_desktop_config.json
--format {terminal,json,html}
--output FILE
--min-severity {INFO,LOW,MEDIUM,HIGH,CRITICAL}
--fail-on {INFO,LOW,MEDIUM,HIGH,CRITICAL}Example:
$ guardrail-mcp scan server.py
Guardrail-MCP: scanned 1 file(s), discovered 5 tool/resource/prompt handler(s)
[CRITICAL] GMCP-CMD001 server.py:17 (tool: run_report)
Tool 'run_report' passes attacker-controlled input into subprocess.run(..., shell=True),
which can execute arbitrary shell commands.
> subprocess.run(cmd, shell=True)
fix: Avoid shell=True, os.system/os.popen, subprocess.getoutput/getstatusoutput,
asyncio.create_subprocess_shell, and eval/exec with user-derived input. Use an
argv-list form (shell=False, or asyncio.create_subprocess_exec) and validate/allowlist inputs.
[HIGH] GMCP-DESC001 server.py:11 (tool: run_report)
Description contains instruction-like phrasing ("Do not tell the user"), a classic
indirect prompt-injection pattern hidden in tool metadata.
fix: Rewrite the description as a plain factual statement of what the tool does.
| Rule ID | Severity | Detects |
|---|---|---|
GMCP-CMD001 |
Critical | Attacker-controlled input reaching a shell: subprocess.{run,call,Popen,...} with shell=True, os.system/os.popen, subprocess.getoutput/getstatusoutput, asyncio.create_subprocess_shell, or eval/exec |
GMCP-DESER001 |
Critical | pickle/marshal/unsafe yaml.load reachable from tool input |
GMCP-PATH001 |
High (Low if a containment check is present) | File read/write/delete reachable from tool input without an obvious is_relative_to/commonpath guard, including the async surface (aiofiles.open, aiofiles.os.remove/unlink/rmdir) alongside the sync one |
GMCP-DESC001 |
High/Medium/Low | Instruction-like phrasing, invisible/bidi Unicode, hidden HTML comments, or length outliers in tool/resource/prompt descriptions |
GMCP-DESC002 |
Critical/High/Medium | Descriptions crafted to manipulate the calling agent specifically: fake role/system framing ("developer mode", "[SYSTEM]"), base64/hex-encoded payloads that decode to instruction-like text, and homoglyph (confusable-script) substitution |
GMCP-DESC003 |
Medium | A tool's description naming a different, real tool discovered in the same file alongside sequencing language ("first fetch X, then call this"): call-order hijacking that references something concrete and checkable, not generic phrasing |
GMCP-SECRET001 |
High | Hardcoded API keys, tokens, and private key material |
GMCP-CAP001 |
Medium | A single handler combining broad data access (files/env) with network egress: a possible exfiltration path |
GMCP-CONFIG001 |
Medium | Whole-config only (scan-config): a server with data-access capability plus a separately configured server with network-egress capability. An exfiltration path across servers that GMCP-CAP001 structurally can't see. |
GMCP-DESC001 and GMCP-DESC002 are the differentiators here. No generic
SAST tool has a concept of "tool description," because that field doesn't
exist outside MCP-style agent tooling, and indirect prompt injection via
tool/resource metadata is an actively discussed MCP attack class, not a
hypothetical one. GMCP-DESC002 targets the sophistication one layer up
from GMCP-DESC001's plain-text patterns: content designed to survive a
casual text scan by only revealing itself once decoded, or by manipulating
the agent's sense of its own identity and permissions rather than using
obviously imperative language. GMCP-DESC003 is structurally different
from both. It's the one rule that looks across every handler in a file at
once instead of one description in isolation, which is what lets it tell
a real cross-reference apart from generic sequencing phrasing. It also
carries real false-positive risk worth naming plainly: legitimate
documentation says "call X first" about genuinely related tools too, which
is why it's calibrated to Medium rather than High.
Guardrail-MCP is static analysis only. It never executes a scanned server
by default. Discovery walks the AST for two patterns confirmed against the
real SDKs: FastMCP's @mcp.tool()/@mcp.resource()/@mcp.prompt()
decorators (the primary target), and the low-level SDK's
@server.call_tool() dispatcher plus Tool(description=...) construction
sites (best-effort).
Taint tracking is a deliberately simple heuristic. It's intraprocedural only, meaning one function body with no cross-function or cross-file following, propagated through direct assignment, f-strings, and concatenation in a single top-to-bottom pass. That's a real trade-off, not an oversight: it keeps false positives low and the algorithm exhaustively testable. It isn't a sound analysis and isn't trying to be one, so treat findings as a strong lead rather than a verdict.
The actual boundary is narrower than "no cross-function following" makes
it sound, and it's worth being precise about rather than leaving it as a
vague disclaimer (tests/test_taint_edge_cases.py pins down both sides of
it against real fixtures). cmd = build_cmd(script_name) still gets
caught, because the check is syntactic: any already-tainted name appearing
anywhere in an assignment's right-hand expression marks the target
tainted, including as an argument inside a call to some other function,
regardless of what that function actually does with it. What's genuinely
missed is when the dangerous sink itself lives inside a separately-defined
helper function that the tool handler merely calls. ast.walk(tool.func)
never descends into a function body that isn't nested inside the one being
walked.
Every rule ships with a matched vulnerable/safe fixture pair under
tests/fixtures/ (uv run pytest). Each vulnerable fixture must be
flagged and each safe counterpart must not be, as a hard false-positive
gate.
Beyond fixtures, the scanner was run against the three Python reference
servers in the official
modelcontextprotocol/servers
repo (fetch, git, time; the others in that repo are TypeScript and
out of scope for this Python-only v1): 13 files, zero findings. That's a
real result, not a null test. These servers genuinely don't use raw
subprocess/shell calls, unsafe deserialization, or unguarded file I/O
(git's file access goes through GitPython rather than raw paths), and
their tool descriptions are short and factual. Confirming zero findings on
professionally-maintained reference code, rather than only on synthetic
fixtures, is the actual point of this validation pass.
That validation pass also found a real gap, not just clean results.
Scanning a third-party community server
(tumf/mcp-shell-server)
turned up zero findings too, but for the wrong reason at first: it uses
asyncio.create_subprocess_exec (the safe, no-shell argv-list form), and
GMCP-CMD001 at the time had no concept of the asyncio subprocess API at
all, sync or dangerous form. A constructed repro using
asyncio.create_subprocess_shell with the exact same tainted-string-
concatenation pattern confirmed it: a real false negative. Fixed by
extending the rule's sink table to cover the async surface alongside the
sync one (asyncio.create_subprocess_shell, plus subprocess.getoutput/
getstatusoutput, found while generalizing the fix rather than
special-casing the one function that happened to be missing). Covered now
by tests/test_async_command_injection.py, including a fixture proving
asyncio.create_subprocess_exec correctly stays unflagged.
The same investigation raised an obvious follow-up: did GMCP-PATH001 have
an analogous async blind spot? Checked rather than assumed. aiofiles.open
turned out to already be covered (the existing .open() check matches on
method name alone, any receiver, so it was never actually a gap), but
aiofiles.os.remove/unlink/rmdir needed a genuinely deeper fix: a
3-level qualified path (aiofiles.os.remove) that the old os.remove
check, which only handled a single-name qualifier, couldn't reach. Fixed
with a general dotted-path resolver instead of one more hardcoded special
case, covered by tests/test_async_path_traversal.py.
Static findings are a strong lead, not a verdict. --verify turns a
GMCP-CMD001 finding into a confirmed exploit by actually calling the
flagged tool. It requires Docker, and for each candidate finding:
- Builds (once, cached by Docker's own layer cache) a locked-down sandbox image: no network, read-only root filesystem, dropped capabilities, non-root user.
- Runs the flagged tool inside a fresh container of that image with a crafted shell-metacharacter payload in every string parameter, network disabled, a wall-clock timeout, and resource limits.
- Before executing, installs a Python audit hook (
sys.addaudithook) that watches forsubprocess.Popen/os.systemactually firing with the payload present, and raises inside the hook to abort the call before the real OS-level exec happens. Confirmation never requires letting an injected command actually run, even inside the sandbox: the container isolation and the audit-hook interception are two independent layers, and both would have to fail for anything real to execute. The payload itself is also inert by design (it touches a file in the container's own throwaway tmpfs) as a third line of defense.
A [CRITICAL] GMCP-CMD001 finding tagged VERIFIED EXPLOITABLE means the
sink was reached and fired for real, in the exact form shown, with the
exact argv it would have executed included in the finding.
Absence of that tag is not a clearance. Every other outcome
(not_confirmed, import_failed, tool_not_found, timeout, error) is
reported and shown but is inconclusive: the targeted payload may simply
not match this handler's call shape, or the module may need dependencies
beyond fastmcp/mcp that aren't in the sandbox image. Scope is
deliberately narrow in this first pass. Only subprocess/os.system/
os.popen-style shell sinks are covered; eval/exec-flagged findings
need syntactically-valid-Python payloads and are left unattempted rather
than checked with the wrong payload shape. PATH001 and DESER001 aren't
covered yet.
Most users run several MCP servers at once through one client config
(Claude Desktop's mcpServers shape, also used verbatim by several other
clients). GMCP-CAP001 only ever looks inside one handler at a time, so
it structurally cannot see that server A reads local files/env while a
completely separate server B has network egress, even though running both
together is a real exfiltration path.
scan-config path/to/config.json works in three steps:
- Resolves each
mcpServersentry to local Python source, deliberately narrowly: an explicit.pyfile or a local directory containing.pyfiles, found among that entry'scommand/args. npm/uvx-published packages, Docker images, and-m packageinvocations aren't resolvable without actually installing and running them, which is out of scope for static analysis, so those entries are reported as skipped and never silently treated as safe. - Runs the normal static rule set against every resolved server, with findings tagged by which server they came from.
- Aggregates a whole-server (not one-handler) capability profile: does
any tool in this server read local data, does any tool perform network
egress, then runs
GMCP-CONFIG001across every pair of different resolved servers.
A GMCP-CONFIG001 finding names both servers and the specific tool/file
behind each half of the pair, so it's actionable rather than just "these
two servers seem risky together."
- name: MCP security scan
run: |
uv tool install guardrail-mcp
guardrail-mcp scan . --fail-on HIGHNo fuzzing or general-purpose dynamic scanning. --verify re-checks
findings the static pass already flagged, and only for GMCP-CMD001; it
doesn't discover new ones. No resolution of npm/uvx/Docker-based MCP
server entries in scan-config, only local .py sources. No JS/TypeScript
support. No taint tracking into a separately-defined helper function's own
body (see "How it works, and its limits" for the precise boundary). No
live scanning of deployed endpoints. No web UI. No auto-fix.
This repository is a ground-up rebuild of a project the author originally began in February 2025 under a different GitHub account that's no longer accessible. Nothing here is restored from that earlier codebase. It's a fresh implementation, published now.
MIT, see LICENSE.