← Back to work

MCP hands tool descriptions to the model as trusted context. Nothing in that trust model has a place for a description that's lying.

Case Study

Auditing MCP servers with Guardrail-MCP

A security scanner purpose-built for MCP servers, built around an attack surface generic static-analysis tools have no vocabulary for: prompt injection hidden inside a tool's own description.

Guardrail-MCP reads an MCP (Model Context Protocol) server's Python source and reports concrete, categorized vulnerabilities: command injection, path traversal, insecure deserialization, hardcoded secrets, and single handlers that combine broad file or environment access with network egress. Nine rules in total, each shipped with a matched vulnerable and safe fixture, where a safe fixture getting flagged counts as a hard failure, not an acceptable false positive.


Its most distinctive capability is three separate techniques for catching prompt injection hidden in a tool's description field — the string handed to the AI model to help it decide whether and how to call a tool, and a field no generic SAST tool has a concept of, because it doesn't exist outside agent-tooling frameworks.

A finding doesn’t have to stay theoretical. --verify can run a flagged tool for real, inside a locked-down, network-disabled Docker container, and prove a command-injection finding is genuinely exploitable, using a Python audit hook that raises a moment before the real syscall would have fired.


scan-config goes a level higher again, reading an entire MCP client configuration and catching risk that only exists once multiple servers run together: one server that can read local files, a separate, independently-configured server with network egress — neither flagged alone, both a problem side by side.

Year

2026

Disciplines

Security tooling

Static analysis

Sandboxed execution

CLI development

What's a tool description doing on an attack surface?

MCP servers are small programs that give an AI agent new capabilities: reading files, making web requests, running shell commands. They're a new, fast-growing, and largely unaudited attack surface, and part of what makes them new is a construct conventional security tooling has no vocabulary for at all: the tool description, a short string handed to the model as trusted context to help it decide whether and how to call a tool.

A generic static-analysis tool has no concept of that field, because outside agent-tooling frameworks it doesn't exist. Someone can hide an instruction inside it — a fake system prompt, a line telling the model not to mention what it's doing, an encoded payload that only reveals itself once decoded — and the model may act on it as a legitimate instruction, because nothing in the standard trust model distinguishes text a developer wrote to describe a tool from text written to manipulate whoever reads it. Guardrail-MCP is built around that specific gap: it understands MCP's own constructs — @mcp.tool(), @server.call_tool(), Tool(description=...) — well enough to reason about them directly, not as generic Python.

Nine rules behind one pipeline

The pipeline is four stages. discovery.py walks a file's AST looking for two patterns confirmed against the real MCP SDKs: FastMCP's @mcp.tool() / @mcp.resource() / @mcp.prompt() decorators, the primary target since most real MCP servers are built on FastMCP, and the low-level SDK's dispatcher and description-construction sites, handled best-effort since that SDK doesn't colocate a handler with its description the way FastMCP does. Every handler discovery finds becomes a tool definition — name, kind, AST node, parameter names, description text — which the rules check and the scanner orchestrates into a report.

discovery.pyparse AST, find tool/resource handlersrules/*nine checks across three plugin interfacesscanner.pyorchestrate and aggregate findingsreport/*terminal, JSON, or HTML

The rule system runs on three separate plugin interfaces, not one, and that split is a deliberate call rather than over-engineering. Six of the nine rules are a plain per-handler check. Hardcoded-secret detection checks a whole file's raw text instead, since a leaked key can appear anywhere in a module, not just inside a tool function. And exactly one rule — GMCP-DESC003, which checks whether a description names a real sibling tool by name — checks every handler in a file together, something a single-handler check structurally cannot see no matter how it's written. Adding a third interface for that one rule kept its blast radius to the one file it needed, instead of changing every other rule's signature to accept sibling context the rest never use.

Three ways of reading a hostile description

The baseline check, GMCP-DESC001, looks for what a description-poisoning attempt often looks like on the surface: obviously instruction-like phrasing ("ignore previous instructions," "do not tell the user"), invisible or bidirectional Unicode control characters, hidden HTML comments, or a description suspiciously long for what should be one line.

GMCP-DESC002 targets content built to survive a casual scan. Role-escalation framing ("[SYSTEM]," "developer mode," "override your instructions") is the easy part. The more interesting sub-check scans for base64 or hex-looking substrings, decodes them, and re-runs the same instruction-pattern checks against what comes out — so a payload that reads as gibberish to a human skimming the description still gets caught, because the check happens after decoding, not before. A third sub-check catches homoglyph substitution: Cyrillic or Greek look-alike characters swapped into English words, so something like "pауload" reads as "payload" to a human eye but is a different sequence of characters to any naive keyword filter. The rule flags the mix and reports both the original text and what it actually reads as.

GMCP-DESC003 is structurally different from both — the one rule built to see across an entire file instead of one handler. It checks whether a description names another tool that genuinely exists elsewhere in the same file, combined with sequencing language ("first fetch X, then call this"), which is a stronger signal than generic imperative phrasing because it requires cross-referencing the real tool registry. It's calibrated to medium severity, not high, and that's a deliberate, documented choice: legitimate documentation really does say "call X first" about real, related tools sometimes, and the false-positive risk is stated in the code and the README rather than left for a user to discover the hard way.

A taint tracker that knows exactly what it can't see

taint.py is a deliberately simple heuristic, not a sound data-flow analysis. It seeds every one of a handler's parameters as tainted — reasonable, since in an MCP tool, all of them ultimately trace back to whatever's calling the tool — then walks the function body once, top to bottom, propagating taint through assignment and loop targets whenever a tainted name shows up anywhere in the expression on the right.

That last clause produces a boundary narrower than "it's not interprocedural" makes it sound. cmd = build_cmd(script_name) still gets flagged as tainting cmd, even though nothing traces into what build_cmd actually does with it — the check is purely syntactic, an overapproximation at the call site that happens to look like interprocedural tracking from the outside. What's actually missed is the opposite case: a dangerous sink living inside a separately defined helper function the handler merely calls, invisible to an AST walk that only descends into nodes lexically nested inside the handler's own body.

That precise boundary wasn't a documentation nicety — both cases were built as real fixtures and the scanner's actual behavior on each was observed, rather than trusting a vaguer original description. Both are now permanent regression tests, including the known-limitation case, which asserts zero findings on a fixture that would ideally be caught, so a future change that narrows or widens the gap gets noticed either way instead of drifting silently.

Proving exploitability without letting anything happen

For GMCP-CMD001 findings, --verify builds a locked-down sandbox image once — pinned FastMCP/MCP versions on python:3.12-slim — then runs the flagged tool inside a fresh container per finding: no network, read-only root filesystem, every capability dropped, a non-root user, hard resource limits. The tool gets called with a shell-metacharacter payload in every string parameter. Before the call happens, a Python audit hook watches for the process-spawning audit events firing with that payload's marker present, and raises from inside the hook, aborting the operation before the real OS-level exec happens.

Audit hookkill switchContainerno networkInert payloadtouch, not rmCONFIRMEDexploitable, proven without it happening

The safety model is three independent layers, and the interesting part isn't the sandboxing on its own — it's that confirmation never requires letting the dangerous action actually happen, even inside the sandbox. The audit hook is a kill switch, not a sensor: because it raises before the real syscall, a confirmed result never means a shell command ran and happened to be harmless, it means the code path was proven to be about to execute it and was stopped first. Container isolation is the backstop if the hook layer has a gap. And the payload itself is inert by construction — a throwaway file touched inside the container's own tmpfs, never something destructive. All three would have to fail at once for anything real to happen.

Two real bugs surfaced while building this, both the kind that only shows up from actually re-running the test suite instead of trusting a design change was correct because it should work. Refactoring how the in-container harness reports its result back to the host — from a single JSON line on stdout, fragile because the target code being tested can freely print to the same stream, to a dedicated result file — broke the existing integration test immediately. The cause: the container runs as a non-root user, and the host-created result file it needed to write to had default, owner-only permissions. The harness crashed with an unhandled permissions error, and the crash traceback itself was what the fallback parser was about to report as "confirmed" evidence. Fixed by loosening the file's permissions before mounting it, and by making the harness's own result-write defensive instead of letting it crash unhandled.

The second bug was the verifier matching a finding back to the wrong live function. Two handlers can share a registered name — one explicitly registered under a name, a second, differently-behaved function separately named the same thing by its own default — and matching by name alone meant whichever tool discovery happened to see first would win. In a constructed test case, that meant poisoning an unrelated parameter on the wrong tool while the actually vulnerable one went untested, silently turning a genuinely confirmable finding into a false negative. The fix prefers the tool whose function body actually contains the finding's line number, falling back to name matching only when no range matches — and both the failure mode and the fix are now locked in as tests, one on the matching logic directly and one slower end-to-end test that runs the real sandboxed pipeline.

When two safe servers add up to one unsafe combination

Most people don't run one MCP server, they run several at once through a single client configuration, the shape Claude Desktop uses and other clients have adopted verbatim. A single-handler rule like GMCP-CAP001 can only ever look inside one handler at a time, so it structurally can't see a real risk: Server A can read local files or environment variables, Server B, a completely separate, independently-configured server, has network egress. Neither gets flagged alone. Running both together creates a path for data to leave the machine.

scan-config resolves each configuration entry to local source narrowly and honestly: only an explicit Python file or a local directory of them counts as resolved, and npm/uvx packages, Docker images, and module invocations that aren't traceable to local source without actually running them get reported as skipped rather than silently treated as clean. It runs the full rule set against every resolved server, then aggregates a whole-server capability profile — not "does one handler have both capabilities" but "does any handler in this server read data, does any handler reach the network" — and runs a cross-server rule, GMCP-CONFIG001, over every pair of resolved servers. The capability-detection logic itself is shared between the single-handler and whole-server checks rather than duplicated, so a future change to what counts as network egress can't drift between the two.

What does that add up to?

Rules

9

Tests

57

Bugs

2

Where it stands, honestly

Guardrail-MCP is v0.1.0. It hasn't been published to PyPI. --verify currently covers exactly one rule family — the shell-sink side of GMCP-CMD001 — and leaves eval/exec findings unattempted rather than testing them with a payload that can't possibly work against an eval context; GMCP-PATH001 and GMCP-DESER001 aren't wired to it yet. The sandbox image only pre-installs FastMCP/MCP, so a real server with its own third-party dependencies will fail to import inside it and come back inconclusive, never treated as a clearance.

The original six rules were validated against the three Python reference servers in the official MCP servers repository with zero findings, a genuine result rather than a null test, since those servers route file access through GitPython instead of raw paths. The three newest rules — the two deeper description checks and the cross-server config check — haven't yet been run against real-world servers, only the synthetic fixtures written to exercise them, a meaningfully easier bar than messy, human-written tool descriptions. That's a stated next step, not a claimed-complete one, and it's stated plainly in the project's own documentation rather than smoothed over here.

Build the world with intent

© 2026 Saksham Banjade