MCP Server Security: Tool Poisoning Defenses and OAuth 2.1 Implementation Guide
MCP servers mitigate tool-poisoning attacks through static detection, hash-pinning, and gateway enforcement, while OAuth 2.1 with PKCE and resource indicators ensures fine-grained authorization for every tool invocation.
Model Context Protocol (MCP) servers expose tools, resources, and prompts directly to LLM agents, creating a unique attack surface where malicious descriptions can hijack model behavior. The rohitg00/ai-engineering-from-scratch repository provides a comprehensive curriculum on securing these endpoints, covering both static defenses against prompt injection and robust authentication flows. Understanding these security considerations for MCP servers is essential for production deployments where tools may access sensitive data or execute destructive operations.
Understanding Tool Poisoning in MCP Servers
Tool poisoning occurs when a compromised or malicious MCP server embeds covert instructions inside a tool's description field. Because LLM agents inject these descriptions directly into their context window, the model may follow hidden commands—such as exfiltrating private keys or ignoring previous instructions—without the user's knowledge.
According to the lesson "MCP Security I — Tool Poisoning, Rug Pulls, Cross-Server Shadowing" in phases/13-tools-and-protocols/15-mcp-security-tool-poisoning/docs/en.md, the specification enumerates seven distinct attack classes, with tool poisoning representing the most critical vector for prompt injection.
Static Detection and Hash-Pinning
The first line of defense involves validating tool manifests before they reach the LLM. The reference implementation in phases/13-tools-and-protocols/15-mcp-security-tool-poisoning/code/main.py demonstrates a static detector that scans descriptions for suspicious patterns and validates cryptographic hashes.
# src: phases/13-tools-and-protocols/15-mcp-security-tool-poisoning/code/main.py
import re, hashlib, json, sys
POISON_PATTERNS = [
r"<SYSTEM>", r"ignore previous", r"\\bhttps?://\\S+",
r"\\b(?:base64|hex)\\b", r"\\bssh[-_]key\\b"
]
def is_poisoned(desc: str) -> bool:
return any(re.search(p, desc, flags=re.IGNORECASE) for p in POISON_PATTERNS)
def hash_desc(desc: str) -> str:
return hashlib.sha256(desc.encode()).hexdigest()
def verify_tool(tool: dict, known_hash: str) -> None:
desc = tool.get("description", "")
if is_poisoned(desc):
sys.exit(f"❌ Poisoned description detected in tool '{tool['name']}'")
if hash_desc(desc) != known_hash:
sys.exit(f"❌ Hash mismatch for tool '{tool['name']}' (possible rug‑pull)")
# Example usage: load a tool manifest and validate
manifest = json.load(open("tool_manifest.json"))
for tool in manifest["tools"]:
verify_tool(tool, known_hash=tool["approved_hash"])
print("✅ All tools passed static checks")
Hash-pinning stores a SHA-256 hash of the approved tool description; any mutation aborts the call, preventing "rug pull" attacks where a server updates a benign tool to a malicious version after initial approval.
Gateway Enforcement and MELON
Beyond client-side checks, production deployments should deploy a gateway enforcement layer. A central registry validates tool manifests and rejects mismatched hashes before they reach the client, as implemented in the capstone project located at phases/19-capstone-projects/13-mcp-server-with-registry/docs/en.md.
The curriculum also introduces MELON (Masked Execution with Label Obfuscation), a defense that involves masked re-execution of the tool without the suspicious description and comparison of outputs to detect behavioral deviations.
OAuth 2.1 Safeguards for MCP Authentication
Remote MCP servers require authenticated and authorized sessions for every tool invocation. The specification mandates a full OAuth 2.1 profile tailored for MCP, detailed in phases/13-tools-and-protocols/16-mcp-security-oauth-2-1/docs/en.md.
Authorization Code Flow with PKCE
The MCP OAuth 2.1 profile requires Authorization Code + PKCE (Proof Key for Code Exchange) for all token requests. This prevents authorization code interception attacks, particularly critical for MCP clients that may operate in browser-based or mobile environments.
Resource Indicators and Audience-Pinning
Following RFC 8707, MCP servers must use resource indicators on every token request, while RFC 9728 protected-resource metadata ensures the client understands the server's capabilities. Audience-pinning binds tokens to the intended MCP server URI, thwarting replay attacks where a valid token for one server is reused against another.
Per-Tool Scopes and Step-Up Consent
The capstone implementation in phases/19-capstone-projects/13-mcp-server-with-registry/code/main.py demonstrates runtime enforcement of fine-grained permissions:
# src: phases/19-capstone-projects/13-mcp-server-with-registry/code/main.py
from fastapi import FastAPI, Request, HTTPException
import jwt, httpx
app = FastAPI()
# Configuration (normally loaded from .well-known/mcp-capabilities)
INTROSPECTION_ENDPOINT = "https://auth.example.com/introspect"
PUBLIC_JWKS_URL = "https://auth.example.com/.well-known/jwks.json"
# Simple in‑memory JWKS cache
_jwks = {}
async def fetch_jwks():
resp = await httpx.get(PUBLIC_JWKS_URL)
resp.raise_for_status()
_jwks.update(resp.json())
@app.on_event("startup")
async def startup():
await fetch_jwks()
def verify_scope(token_payload: dict, required_scope: str):
scopes = token_payload.get("scp", "").split()
if required_scope not in scopes:
raise HTTPException(status_code=403, detail="Insufficient scope")
@app.middleware("http")
async def oauth_middleware(request: Request, call_next):
auth = request.headers.get("Authorization")
if not auth or not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
token = auth.split()[1]
# Introspect token (RFC 7662 style)
async with httpx.AsyncClient() as client:
resp = await client.post(
INTROSPECTION_ENDPOINT,
data={"token": token},
auth=("client_id", "client_secret"),
)
resp.raise_for_status()
introspection = resp.json()
if not introspection.get("active"):
raise HTTPException(status_code=401, detail="Inactive token")
# Verify JWT signature (fallback if introspection not provided)
header = jwt.get_unverified_header(token)
kid = header["kid"]
key = next((k for k in _jwks["keys"] if k["kid"] == kid), None)
if not key:
await fetch_jwks()
key = next(k for k in _jwks["keys"] if k["kid"] == kid)
payload = jwt.decode(token, key, algorithms=["RS256"], audience="my-mcp-server")
# Enforce per‑tool scope (example: tool requires "jira:read")
required = request.path_params.get("required_scope")
if required:
verify_scope(payload, required)
request.state.jwt = payload
return await call_next(request)
Per-tool scopes (e.g., jira:read, s3:list) are validated at call time, preventing over-privileged access. For sensitive operations, step-up (incremental) consent (SEP-835) forces re-authentication when a request requires higher-privilege scopes, enabling human-in-the-loop approval workflows such as Slack cards for destructive actions.
Production-Grade Implementation
The capstone project "MCP Server with Registry & Governance" integrates these security layers into a production-grade deployment. Located at phases/19-capstone-projects/13-mcp-server-with-registry/docs/en.md, this implementation combines StreamableHTTP transport, OAuth 2.1 scope enforcement, Open Policy Agent (OPA) for additional authorization logic, and a centralized registry for tool manifest validation. Together, these components establish a defense-in-depth posture where static checks catch malformed descriptions before they reach the LLM, while OAuth 2.1 guarantees that only authorized principals can invoke specific tools.
Summary
- Tool poisoning exploits the fact that LLM agents inject tool descriptions directly into prompts, allowing hidden instructions to compromise model behavior.
- Static detection using regex patterns for suspicious tags like
<SYSTEM>or "ignore previous" combined with hash-pinning prevents rug-pull attacks and prompt injection. - Gateway enforcement through a central registry validates tool manifests and hashes before they reach clients, with MELON providing behavioral verification.
- OAuth 2.1 is mandatory for remote MCP servers, requiring Authorization Code + PKCE, resource indicators (RFC 8707), and protected-resource metadata (RFC 9728).
- Audience-pinning and per-tool scopes prevent token replay and over-privileged access, while step-up consent enables human approval for destructive operations.
Frequently Asked Questions
What is tool poisoning in MCP servers?
Tool poisoning is an attack where a malicious MCP server embeds covert instructions inside a tool's description field. When an LLM agent loads this tool, the description is injected into the model's context window, potentially causing the model to execute unauthorized actions such as reading private keys or ignoring previous instructions.
How does hash-pinning prevent tool poisoning?
Hash-pinning stores a SHA-256 hash of the approved tool description in the client configuration. Before invoking any tool, the client recalculates the hash of the current description and compares it against the stored value. Any mismatch indicates the tool has been modified (a "rug pull"), and the client aborts the call before the LLM processes the potentially poisoned description.
Why is OAuth 2.1 required for remote MCP servers?
Remote MCP servers expose tools over the network, requiring both authentication (verifying the user's identity) and authorization (verifying permissions for specific actions). The MCP OAuth 2.1 profile mandates PKCE for secure token exchange, resource indicators to specify the target server, and per-tool scopes to ensure least-privilege access, preventing unauthorized invocation of sensitive operations.
What are resource indicators in MCP OAuth flows?
Resource indicators, defined in RFC 8707, are parameters included in OAuth token requests that specify the exact MCP server URI for which the token is valid. This enables audience-pinning, ensuring that tokens issued for one MCP server cannot be replayed against a different server, effectively mitigating cross-server token replay attacks.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →