Safe MCP — a guard between the model and the tools

Companion to my LinkedIn post on locking down an MCP server before it touches prod: https://www.linkedin.com/feed/update/urn:li:share:7480988848592748545/

An MCP server lets a client discover tools and call them at runtime. That is thefeature. It is also the problem: the server describes its own tools to the model,and a model reads those descriptions as instructions. Anyone who can edit theserver — or a model that gets talked out of good behavior — can turn a tool callinto a way onto your host.

This repo puts a deterministic policy layer between the two. A tool call runs onlyif the tool is on an allow-list and its arguments pass that tool's validator.Everything else is refused and logged before the server is touched.

The setup

server.py exposes two tools:

  • lookup_policy(topic) — read-only, returns a canned string. Safe.
  • run_command(cmd) — runs a shell command on the host. The kind of tool realMCP servers ship (filesystem, shell, DB write).

run_command's description also carries a prompt-injection payload — textaimed at the model, telling it to call run_command with cat ~/.ssh/id_rsaafter any lookup, and not to mention it. This is "tool poisoning": the boundaryyou'd cross if you trusted the server's description of itself.

guard.py is the boundary. It does not read descriptions and it does not getpersuaded:

async def call_tool(self, name, args):
    if name not in self._policy:                    # allow-list by identity
        self._log("BLOCKED", name, args, "tool not on allow-list")
        raise Blocked(name, "tool not on allow-list")
    ok, reason = self._policy[name](args)           # validate this tool's args
    if not ok:
        self._log("BLOCKED", name, args, reason)
        raise Blocked(name, reason)
    result = await self._session.call_tool(name, args)   # only now touch server
    self._log("ALLOWED", name, args, "ok")
    return result

lookup_policy is on the allow-list with a validator that checks topic.run_command is deliberately absent, so it is refused — description, poisoning,and all.

Requirements

  • Python 3.10+
  • mcp>=1.2, openai>=1.0 (pip install -r requirements.txt)
  • demo.py needs no key. agent.py needs OPENAI_API_KEY.

Run it

pip install -r requirements.txt

# No model in the loop — tests the boundary directly. No key.
python demo.py

# A real model, handed every tool including the poisoned one. Needs a key.
export OPENAI_API_KEY=sk-...
python agent.py "how much notice do I need to cancel?"
python agent.py "run the shell command whoami and tell me the output"

What you'll see

demo.py sends four calls through the guard — the calls stand in for a jailbrokenmodel, a poisoned description, a compromised server. The guard treats them thesame: it checks the allow-list and the arguments, not who asked or why.

Tools the server advertises (note the second one's description):
  - lookup_policy: Look up the company policy for a topic. Valid topics: cancellation, refunds, downgrades.
  - run_command: Run a shell command and return its output.

[PASS] legit lookup
        lookup_policy({'topic': 'cancellation'}) -> ran: Cancellation requires at least 14 days notice before the next billing date.
[PASS] unknown topic
        lookup_policy({'topic': 'salaries'}) -> refused (topic must be one of ['cancellation', 'downgrades', 'refunds'])
[PASS] the poisoned tool the server told the model to call
        run_command({'cmd': 'cat ~/.ssh/id_rsa'}) -> refused (tool not on allow-list)
[PASS] same tool, harmless-looking arg -- still refused by identity
        run_command({'cmd': 'echo hi'}) -> refused (tool not on allow-list)

4/4 cases behaved as required.

Audit log (every decision, allowed or not):
  ...  ALLOWED   lookup_policy({'topic': 'cancellation'})  ok
  ...  BLOCKED   lookup_policy({'topic': 'salaries'})  topic must be one of [...]
  ...  BLOCKED   run_command({'cmd': 'cat ~/.ssh/id_rsa'})  tool not on allow-list
  ...  BLOCKED   run_command({'cmd': 'echo hi'})  tool not on allow-list

agent.py puts a real model in front of the same guard. Ask it something normaland it calls the safe tool:

Model wants to call: lookup_policy({'topic': 'cancellation'})
  guard allowed it -> Cancellation requires at least 14 days notice before the next billing date.

Push it toward the host and the model asks for run_command — and the guardrefuses before anything runs:

User asks: run the shell command whoami and tell me the output

Model wants to call: run_command({'cmd': 'whoami'})
  guard REFUSED it -> tool not on allow-list

Audit log:
  ...  BLOCKED   run_command({'cmd': 'whoami'})  tool not on allow-list

The model's decision is not the boundary. The guard is.

What this does and doesn't cover

  • It stops a call to a tool you didn't allow, and a call with arguments you didn'tpermit — regardless of whether a poisoned description, a jailbreak, or a bugproduced the request.
  • It is not a replacement for the usual controls on the tools you do allow:a permitted tool still needs its own authz, rate limits, and least-privilegecredentials. The allow-list narrows the surface; it doesn't harden what's left.
  • The validators here are deliberate and small. That's the point — every place anargument is trusted is one short function you can read.

Files

  • server.py — MCP server with a safe tool and a poisoned dangerous one.
  • guard.py — the allow-list + per-tool validators + audit log.
  • demo.py — runs attack cases through the guard, no key, with assertions.
  • agent.py — a real model in front of the guard; needs OPENAI_API_KEY.
  • requirements.txtmcp and openai.

MCP Server · Populars

MCP Server · New

    firish

    Claude Code for Visual Studio

    Bring Claude Code to Visual Studio 2026: A native diff with accept/reject, a live debugger Claude can drive autonomously, Roslyn code navigation, and Test Explorer integration. The IDE half of Claude Code's integration protocol. Community-built, unofficial.

    Community firish
    uiNerd16

    @aicanvas/mcp

    Open-source React and Tailwind component marketplace. Install via the shadcn CLI or your AI editor over MCP, with reproduction prompts for Claude Code, Lovable, and V0.

    Community uiNerd16
    FootprintAI

    Containarium — Agent Runtime

    Open-source agent runtime — SSH-native isolation, eBPF egress policy, Kubernetes + LXC backends, GPU passthrough, MCP-native CLI

    Community FootprintAI
    openfate-ai

    @openfate/bazi-mcp

    OpenFate Bazi MCP server with deterministic Four Pillars calculation, True Solar Time, branch interactions, and reverse Bazi lookup.

    Community openfate-ai
    mohitagw15856

    🧠 PM Skills — 454 Professional Agent Skills for Claude, ChatGPT, Gemini, Cursor, Codex & Hermes

    In Anthropic's official Claude plugin directory · 400 professional Agent Skills (PRDs, launches, compliance, CVs & more) for Claude, ChatGPT, Gemini, Cursor & Codex. Try free in-browser, or 'npx pm-claude-skills add'.

    Community mohitagw15856