Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Guardrail-MCP

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.

Why

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.

Install

uv tool install guardrail-mcp   # or: pip install guardrail-mcp, once published

From 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.py

Usage

guardrail-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.

What it checks

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.

How it works, and its limits

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.

Real-world validation

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.

Dynamic verification (--verify)

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:

  1. 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.
  2. 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.
  3. Before executing, installs a Python audit hook (sys.addaudithook) that watches for subprocess.Popen/os.system actually 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.

Multi-server config scanning (scan-config)

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:

  1. Resolves each mcpServers entry to local Python source, deliberately narrowly: an explicit .py file or a local directory containing .py files, found among that entry's command/args. npm/uvx-published packages, Docker images, and -m package invocations 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.
  2. Runs the normal static rule set against every resolved server, with findings tagged by which server they came from.
  3. 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-CONFIG001 across 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."

CI usage

- name: MCP security scan
  run: |
    uv tool install guardrail-mcp
    guardrail-mcp scan . --fail-on HIGH

Non-goals (v1)

No 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.

Project history

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.

License

MIT, see LICENSE.

About

Security scanner for MCP servers. Finds vulnerabilities statically, confirms them dynamically in a sandbox, and catches attacks other scanners can't see like agent-targeted prompt injection hidden in tool descriptions.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages