← Agent Security Ideas
Agent Security Ideas 2026-11-05

Mcp Tool Rugpull Gate

Diffs every MCP server's live tool manifest against the version a human approved, and holds calls to anything that changed since.

Mcp Tool Rugpull Gate

MCP Tool Rug-Pull Gate

Diffs every MCP server's live tool manifest against the version a human approved, and holds calls to anything that changed since.

Date: 2026-11-05 Theme: Prompt Injection & Tool Poisoning Type: Web App Platform: MCP Status: Prototype

What it is

A console that keeps one approved snapshot per MCP tool — its description, inputSchema, and annotations — and re-diffs it against the live tools/list response on every scan. When a tool's behavior-relevant fields drift from that snapshot, the console flags it, explains what changed in plain language, and shows every call that was held against it until a reviewer signs off on the new version.

Who it serves

The platform or security engineer who approved an MCP server once, months ago, and has no way to know if it's still the server they approved. Also the reviewer who gets a tools/list_changed notification and needs to decide, in under a minute, whether the change is a routine update or something to escalate.

The agent security pain it addresses

MCP tool definitions are plain JSON a server controls and a client trusts after one look. Invariant Labs' April 2025 disclosure named the "rug pull": a server that passes review, then silently rewrites a tool's description, schema, or declared behavior after approval, with no mechanism in the protocol forcing a second look. OWASP's MCP Top 10 folded this into MCP03:2025, Tool Poisoning, alongside the related trick of widening a tool's reach through its annotations instead of its visible text.

The spec gives servers an optional listChanged capability and a notifications/tools/list_changed message, but the notification only says something changed — not what, and not whether it matters. The spec is also explicit that clients must treat annotation fields as untrusted unless the server is already trusted, which is exactly the gap a rug pull exploits: trust, once granted, isn't re-checked when the trusted server's own output changes. The ETDI paper (Ramesh et al., 2025) proposes closing this with cryptographic signatures bound to a specific tool version, so any edit breaks the signature and forces re-approval. This prototype borrows that enforcement idea — approve a version, gate on drift from it — without requiring the signing infrastructure, so it works against any MCP server today.

The sample data seeds three things that should jump out immediately. A CRM server's send_customer_email tool picked up a line in its description instructing an automatic BCC to an address nobody approved, plus a new optional schema field that lets a caller redirect the send. A ticketing server's close_ticket tool kept its description word-for-word but added a notify_webhook field that can POST a closed ticket's full contents, including customer data, to any URL — proof that a description-only check would have missed it. And the same CRM server is now running a bulk export_contact_list tool that was never submitted for approval at all. A call already landed against two of these before anyone looked.

How it works

  • Banner. On load, the single highest-risk drifted or unapproved tool is named up front, along with whether a call has already been held against it.
  • Tool inventory. Every MCP tool in scope, sorted so drifted and unapproved tools rise to the top, filterable by status and searchable by server or tool name.
  • Manifest diff. Selecting a tool shows a word-level highlighted diff of its description, an added/removed/changed list for its inputSchema properties, and a before/after table for its annotations with changed hints called out — because, as with close_ticket, the description can stay identical while the schema or annotations do the damage.
  • Risk findings. Plain-language reasons tied to the specific fields that moved, not just a severity number.
  • Call log. Every recent call attempt against the selected tool, each marked allowed or blocked with the reason, so a reviewer can see what the gate already caught.
  • Review actions. "Approve new baseline" or "Approve as new tool" clears the drift and resets the approved snapshot to the current one; "Quarantine tool" blocks all further calls regardless of risk score. Both are local UI state changes only.
  • Contrast case. list_ticket_tags changed only its display title. The gate leaves it marked unchanged, because flagging cosmetic edits teaches reviewers to ignore the gate.

What's in this folder

  • README.md — this file
  • cover-image.png — cover art for the idea's card and detail page
  • metadata.md — generation metadata and sourcing
  • requirements.md — the tool-manifest schema, risk rules, and functional requirements
  • index.html — the single-page app; carries an inline copy of the sample data so it still runs if fetch() of local JSON is blocked
  • style.css — layout and the navy/electric-blue/amber visual treatment
  • script.js — manifest loading, the word-level description diff, schema and annotation diffing, filtering, and the approve/quarantine actions
  • sample-data.json — 14 tools across 4 servers and 7 logged call attempts, including the three seeded drift cases above

How to run / read it

Open index.html in any modern browser. No build step, no server, no login. If your browser blocks fetch() of a local JSON file, the page falls back to an inline copy of the same data embedded in index.html.

Estimated impact

No field number — this is a prototype against sample data, not a deployment. The honest claim is about what currently has to happen by hand: a reviewer who wants to know if an approved MCP server changed has to re-read its full tools/list response against whatever they remember approving, for every server, on some manual cadence. At a few dozen MCP servers with a handful of tools each, that's a tedious diff a human will eventually skip. Automating the diff and holding calls against anything that moved turns "did this server change" from a question nobody asks into one that answers itself before a call goes through.

Adapters and scale

One adapter: any MCP server reachable over the tools/list and tools/call methods defined in the 2025-06-18 specification, plus the notifications/tools/list_changed message where a server declares it. The adapter needs read access to each server's tool manifest and a place to persist the last-approved snapshot per tool — no write access to the servers themselves. At 100+ servers and thousands of calls a day, the expensive part is the manifest polling, not the diffing: comparing two small JSON objects for a description, a handful of schema properties, and four annotation booleans is cheap per tool, so the design scales with server count and poll frequency rather than call volume. Gating calls at thousands-per-day scale means the approval-state lookup needs to sit in front of the call path, which this prototype only simulates against a static log.

Sources

  1. Official documentation / standards:
    • https://modelcontextprotocol.io/specification/2025-06-18/server/tools — the tool definition fields (name, title, description, inputSchema, annotations), the listChanged capability, and the requirement that clients treat annotations as untrusted unless the server is already trusted
    • https://owasp.github.io/www-project-mcp-top-10/ — MCP03:2025 Tool Poisoning, including rug pulls and schema poisoning as named sub-techniques
  2. Community / current research:
    • https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks — Invariant Labs' April 2025 disclosure that first named and demonstrated the rug-pull pattern this idea gates against
    • https://arxiv.org/pdf/2506.01333 — ETDI, proposing cryptographic signatures bound to an approved tool version so any post-approval edit forces re-approval, the enforcement model this prototype borrows without the signing layer
Requirements

Requirements — MCP Tool Rug-Pull Gate

Purpose

Detect when an already-approved MCP tool's manifest (description, inputSchema, or annotations) changes after approval, surface what changed and why it matters, and hold tool calls against the drifted definition until a reviewer re-approves it.

Data model

Normalized tool-manifest record (sample-data.json → tools[]). Fields follow the documented MCP tool object (name/title/description/inputSchema/annotations) per modelcontextprotocol.io/specification/2025-06-18/server/tools:

  • id — <server_id>.<tool_name>
  • server_id, tool_name
  • status — one of:
    • unchanged — current hash matches approved hash
    • drifted — behavior-relevant fields (description, input_schema, or annotations) changed; risk-bearing
    • drifted_benign — only cosmetic fields (title) changed; description/input_schema/annotations hash unchanged
    • new_unapproved — tool observed on a server with no prior approval record
    • quarantined — reviewer action, blocks all calls regardless of drift state
  • risk — none | low | medium | high, assigned by the rules below
  • approved_at, approved_by, approved_hash — null for new_unapproved
  • current_hash, observed_at — from the latest scan
  • drift_detected_at — first scan where current_hash diverged from approved_hash
  • approved / current — each an object with title, description, input_schema (map of property → {type, required, description}), annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)
  • risk_reasons — array of plain-language findings tied to specific field changes

Tool-call log record (recent_calls[]), shaped like an OCSF/OpenTelemetry span attribute set would carry it in a real deployment:

  • id, timestamp, tool_id, agent_name, arguments_summary
  • gate_decision — allowed | blocked
  • reason — plain-language tie to the manifest hash state at call time

Risk rules (illustrative — this prototype's own design, not a documented scoring standard)

  1. new_unapproved → risk: high always. A tool with no approval record is the clearest case: nobody has seen it.
  2. Description text change → risk: high if the new text adds an instruction with an external address, URL, or conditional directive (e.g. "always BCC…", "notify this webhook…"); otherwise medium.
  3. input_schema gains a property not in the approved baseline → risk: high if the new property's description implies an external destination (email, URL, webhook); otherwise medium.
  4. annotations.openWorldHint flips false → true → contributes at least medium, high if paired with a schema or description change on the same tool.
  5. Only title differs, all other fields byte-identical → status: drifted_benign, risk: none. This rule exists specifically so cosmetic edits don't desensitize reviewers to real drift — see OWASP's MCP03:2025 framing of rug pulls as a supply-chain-style trust problem, not a text-fuzzing problem.
  6. quarantined tools always gate every call regardless of any other rule.

Functional requirements

  • FR1: On load, compute status and risk for every tool from approved vs current (already pre-computed in sample data here; a live build would run this scan-side).
  • FR2: Render a top-level banner naming the single highest-risk tool, its server, what changed, and whether a call has already been held against it — must be visible without scrolling or clicking.
  • FR3: Tool inventory table, sortable with drifted/unapproved rows first, filterable by status and free-text (server or tool name).
  • FR4: Selecting a tool renders a manifest diff: word-level highlighted diff for description, an added/removed/changed list for input_schema properties, and a before/after table for annotations with changed rows highlighted.
  • FR5: Selecting a tool also renders its risk_reasons and every recent_calls entry referencing it, each tagged allowed/blocked with its reason.
  • FR6: "Approve new baseline" (on drifted/drifted_benign) and "Approve as new tool" (on new_unapproved) buttons update local state only — set status: unchanged, approved = current, approved_hash = current_hash — and are visibly labeled as local-only, no backend call.
  • FR7: "Quarantine tool" sets status: quarantined and visually marks every one of that tool's recent_calls rows as held, regardless of their original gate_decision.
  • FR8: No tool, server, or agent name may resemble a real company, product, or person.

Non-functional

  • Pure static site: index.html + style.css + script.js + sample-data.json. No build step, no external auth, no CDN dependency required for the core diff/gate logic.
  • fetch() of sample-data.json must fall back to an inline <script type="application/json"> copy embedded in index.html, since file:// fetches are blocked in some browsers.
  • Any artifact resembling a wire-protocol object (manifest hash, gate decision, annotations) is structurally illustrative and must be validated against a real MCP client/server pair before use — this prototype does not implement MCP transport, cryptographic signing, or OAuth.

More from Agent Security Ideas