Four fail-closed gates stop a poisoned MCP tool call before dispatch

Asked:

“Build your own MCP Tool Poisoning Shield (JSON-RPC sanitization + Pydantic validation + confused-deputy blocking)”

A reference implementation for Python developers: one gateway, four ordered gates — bounded JSON-RPC 2.0 sanitization, strict Pydantic v2 validation, a confused-deputy policy point, and manifest-integrity checks — backed by a 14-case adversarial pytest plan and a control matrix of 6 primary-source controls from 5 independent hosts (no URL backs more than one row).

Reserved rpc.* methods, reused request IDs, and params whose member names do not match the server’s exactly must be rejected with pre-defined codes such as -32600 and -32602 — json-rpc.org
Pydantic v2 extra='forbid' plus strict validation makes unknown or coerced fields raise instead of being silently accepted — docs.pydantic.dev
MCP guidance: verify every inbound request, never use sessions for authentication, and bind secure non-deterministic session IDs to the user (<user_id>:<session_id>) — modelcontextprotocol.io
RFC 8707 resource indicators audience-restrict tokens to one absolute URI, so a token minted for the gateway cannot be redirected to another resource — www.rfc-editor.org

Gates 1–2: bounded decoding and strict models

# shield.py — MCP Tool Poisoning Shield (Python 3.11+, pydantic>=2)
import hashlib, json, re, unicodedata
from typing import Any, Literal, Annotated
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationError

MAX_BYTES, MAX_DEPTH, MAX_STR, MAX_ITEMS = 64_000, 8, 4_000, 128
SAFE_ERRORS = {-32600: "Invalid request", -32601: "Method not found",
               -32602: "Invalid params", -32000: "Denied"}

class ShieldError(Exception):
    def __init__(self, code: int): self.code = code
    def to_jsonrpc(self, req_id=None):      # never leak validation internals
        return {"jsonrpc": "2.0", "id": req_id,
                "error": {"code": self.code, "message": SAFE_ERRORS[self.code]}}

# ---------- Gate 1: bounded decode + envelope sanitization ----------
def _no_dupes(pairs):                       # duplicate-key rejection
    d = {}
    for k, v in pairs:
        if k in d: raise ShieldError(-32600)
        d[k] = v
    return d

_CTRL = re.compile(r"[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f"
                   r"\u200b-\u200f\u202a-\u202e\u2066-\u2069]")

def _scrub(x: Any, depth: int = 0) -> Any:  # depth / size / Unicode defenses
    if depth > MAX_DEPTH: raise ShieldError(-32600)
    if isinstance(x, str):
        if len(x) > MAX_STR or _CTRL.search(unicodedata.normalize("NFC", x)):
            raise ShieldError(-32600)
    elif isinstance(x, dict):
        if len(x) > MAX_ITEMS: raise ShieldError(-32600)
        for k, v in x.items(): _scrub(k, depth + 1); _scrub(v, depth + 1)
    elif isinstance(x, list):
        if len(x) > MAX_ITEMS: raise ShieldError(-32600)
        for v in x: _scrub(v, depth + 1)
    return x

def decode(raw: bytes) -> dict:
    if len(raw) > MAX_BYTES: raise ShieldError(-32600)
    try:
        return _scrub(json.loads(raw, object_pairs_hook=_no_dupes))
    except ShieldError: raise
    except Exception: raise ShieldError(-32600)

# ---------- Gate 2: strict Pydantic v2 models, no coercion ----------
Name = Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_]{0,63}$")]

class ToolCallParams(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
    name: Name
    arguments: dict[str, Any] = Field(default_factory=dict)

class RpcRequest(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
    jsonrpc: Literal["2.0"]
    id: Annotated[str, StringConstraints(min_length=1, max_length=128)] | int
    method: Literal["tools/call", "tools/list"]   # method allowlist
    params: ToolCallParams | None = None

Gates 3–4: confused-deputy policy and manifest integrity

# ---------- Gate 3: policy enforcement point (confused-deputy block) ----------
class AuthContext(BaseModel):               # built by trusted middleware,
    model_config = ConfigDict(frozen=True)  # NEVER from request params
    principal: str; tenant: str; session_id: str
    token_audience: str                     # RFC 8707 resource indicator
    scopes: frozenset[str]
    origin_request_id: str                  # originating user request

class ToolEntry(BaseModel):
    model_config = ConfigDict(frozen=True)
    name: str; tenant: str
    arg_model: type[BaseModel]              # per-tool argument model
    required_scopes: frozenset[str]
    manifest_sha256: str                    # pinned at review time
    risk_tier: Literal["low", "high"]
    handler: Any

EXPECTED_AUDIENCE = "https://mcp.example.internal/gateway"

def authorize(ctx: AuthContext, entry: ToolEntry, origin_request_id: str):
    if ctx.token_audience != EXPECTED_AUDIENCE: raise ShieldError(-32000)
    if entry.tenant != ctx.tenant:            raise ShieldError(-32000)
    if not entry.required_scopes <= ctx.scopes: raise ShieldError(-32000)
    if ctx.origin_request_id != origin_request_id: raise ShieldError(-32000)

# ---------- Gate 4: manifest integrity + poisoning heuristics ----------
POISON = re.compile(r"(ignore (all|previous)|do not tell|\.ssh|id_rsa|"
                    r"api[_ ]?key|exfiltrat|send .* to http|base64 and post|"
                    r"<!--|\u200b)", re.I)   # defense-in-depth only — NOT a
                                              # complete prompt-injection detector
def manifest_ok(entry: ToolEntry, manifest: dict) -> bool:
    digest = hashlib.sha256(json.dumps(manifest, sort_keys=True,
                            separators=(",", ":")).encode()).hexdigest()
    if digest != entry.manifest_sha256: return False
    text = " ".join(str(v) for v in manifest.values())
    return not POISON.search(text)          # never exec/import manifest text

# ---------- Gateway: fail closed before dispatch ----------
class Shield:
    def __init__(self, registry: dict[str, ToolEntry]):
        self.registry, self.seen_ids = registry, set()

    def handle(self, raw: bytes, ctx: AuthContext, manifests: dict,
               origin_request_id: str, confirmed: bool = False) -> dict:
        req_id = None
        try:
            body = decode(raw)                                  # gate 1
            req = RpcRequest.model_validate(body)               # gate 2
            req_id = req.id
            key = (ctx.session_id, req.id)                      # replay check
            if key in self.seen_ids: raise ShieldError(-32600)
            self.seen_ids.add(key)
            entry = self.registry.get(req.params.name) if req.params else None
            if entry is None: raise ShieldError(-32601)
            try:                                                # per-tool args
                args = entry.arg_model.model_validate(req.params.arguments)
            except ValidationError: raise ShieldError(-32602)
            authorize(ctx, entry, origin_request_id)            # gate 3
            if not manifest_ok(entry, manifests[entry.name]):   # gate 4
                raise ShieldError(-32000)
            if entry.risk_tier == "high" and not confirmed:     # confirmation
                raise ShieldError(-32000)
            audit(ctx, entry, req_id)                           # redacted audit
            result = entry.handler(**args.model_dump())         # safe dispatch
            return {"jsonrpc": "2.0", "id": req_id, "result": result}
        except ShieldError as e:
            audit_denied(ctx, req_id, e.code)
            return e.to_jsonrpc(req_id)
        except ValidationError:
            return ShieldError(-32600).to_jsonrpc(req_id)

def audit(ctx, entry, req_id):              # redacted: no argument values
    print(json.dumps({"evt": "allow", "principal": ctx.principal,
                      "tenant": ctx.tenant, "tool": entry.name, "id": str(req_id)}))

def audit_denied(ctx, req_id, code):
    print(json.dumps({"evt": "deny", "principal": ctx.principal,
                      "id": str(req_id), "code": code}))

The regex scan over manifest text is defense-in-depth only, not a complete prompt-injection detector — per OWASP MCP03, static inspection of names, descriptions and schema text flags model-directed imperatives, sensitive paths, exfiltration patterns and hidden characters as signals for human review; it never proves a manifest safe. Nothing from a tool description or schema is ever executed or dynamically imported. The AuthContext is built by trusted middleware from the verified token — never from request params — so a model cannot assert its own identity, tenant, audience or scopes.

Adversarial test plan: 13 denials, 1 allow

# test_shield.py — adversarial pytest plan (every case must be DENIED
# except the last). env() builds a Shield with one registered tool
# "read_doc" (args: doc_id str), tenant "t1", scope "docs:read", a pinned
# manifest hash, and a valid AuthContext with aud = EXPECTED_AUDIENCE.
import json, pytest
from shield import Shield, ShieldError, EXPECTED_AUDIENCE

def call(name="read_doc", args=None, **env_over):
    s, ctx, manifests = env(**env_over)
    raw = json.dumps({"jsonrpc": "2.0", "id": "r1", "method": "tools/call",
        "params": {"name": name, "arguments": args or {"doc_id": "d1"}}}).encode()
    return s.handle(raw, ctx, manifests, origin_request_id=ctx.origin_request_id)

def test_unknown_envelope_field():          # extra='forbid' at the envelope
    s, ctx, m = env()
    raw = b'{"jsonrpc":"2.0","id":"r1","method":"tools/call","params":{"name":"read_doc","arguments":{}},"evil":1}'
    assert s.handle(raw, ctx, m, ctx.origin_request_id)["error"]["code"] == -32600

def test_coercion_blocked():                # strict=True: int 1 is not "1"
    assert call(args={"doc_id": 1})["error"]["code"] == -32602

def test_duplicate_json_keys():
    s, ctx, m = env()
    raw = b'{"jsonrpc":"2.0","jsonrpc":"2.0","id":"r1","method":"tools/call"}'
    assert s.handle(raw, ctx, m, ctx.origin_request_id)["error"]["code"] == -32600

def test_oversized_payload():
    s, ctx, m = env()
    assert s.handle(b"x" * 100_000, ctx, m, ctx.origin_request_id)["error"]["code"] == -32600

def test_deep_payload():
    s, ctx, m = env()
    raw = (b'{"a":' * 50) + b"1" + (b"}" * 50)
    assert s.handle(raw, ctx, m, ctx.origin_request_id)["error"]["code"] == -32600

def test_rpc_reserved_method():             # method allowlist rejects rpc.*
    s, ctx, m = env()
    raw = b'{"jsonrpc":"2.0","id":"r1","method":"rpc.discover"}'
    assert s.handle(raw, ctx, m, ctx.origin_request_id)["error"]["code"] == -32600

def test_unknown_tool():
    assert call(name="drop_tables")["error"]["code"] == -32601

def test_extra_tool_argument():
    assert call(args={"doc_id": "d1", "sudo": True})["error"]["code"] == -32602

def test_cross_tenant():                    # confused deputy: wrong tenant
    assert call(ctx_tenant="t2")["error"]["code"] == -32000

def test_wrong_audience():                  # RFC 8707 token for another API
    assert call(ctx_aud="https://other.api")["error"]["code"] == -32000

def test_missing_scope():
    assert call(ctx_scopes=frozenset())["error"]["code"] == -32000

def test_absent_origin_request():           # no correlated user request
    assert call(ctx_origin="")["error"]["code"] == -32000

def test_manifest_mutation():               # hash pin breaks on any edit
    assert call(mutate_manifest=True)["error"]["code"] == -32000

def test_replayed_id():
    s, ctx, m = env()
    raw = json.dumps({"jsonrpc": "2.0", "id": "r1", "method": "tools/call",
        "params": {"name": "read_doc", "arguments": {"doc_id": "d1"}}}).encode()
    assert "result" in s.handle(raw, ctx, m, ctx.origin_request_id)
    assert s.handle(raw, ctx, m, ctx.origin_request_id)["error"]["code"] == -32600

def test_valid_call_passes():
    assert call()["result"] == {"doc_id": "d1", "text": "ok"}

Control matrix: six primary-source controls

GateControl (primary source)Implementation in the shieldThreat it blocksSource

Deployment notes

Control matrix: 6 primary-source security controls across 5 independent hosts (OWASP MCP Top 10, JSON-RPC 2.0 spec, MCP security best practices, Pydantic v2 docs, RFC 8707), compiled as supplied; no URL backs more than one row. Each row carries the control statement, implementation guidance and, where the source names one, the threat (3 of 6 rows). Code is a compact reference implementation and test plan, not a drop-in product; long source quotations trimmed in prose but shown in full in the table.

This report was generated automatically by Keenable SELECT at a user's request, from publicly available web sources linked herein. Keenable does not review, verify, or endorse its contents and makes no representation as to accuracy, completeness, or timeliness; AI-based extraction may contain errors. Nothing in this report is investment, legal, financial, or other professional advice. All trademarks and referenced content remain the property of their respective owners; no affiliation or endorsement is implied. To report an error, rights concern, or request removal: legal@keenable.ai.

Keenable SELECTAsk your own question
Made with Keenable SELECT