Mcp server security scan
Skill Dolphinllc/claude-security-skills/skills/defensive/genai/mcp-server-security-scan
Defensive security skills for Claude Code and the Claude Agent SDK — web applications and generative AI systems.
npx -y skills add Dolphinllc/claude-security-skills --skill mcp-server-security-scanAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Defensive security scan for Model Context Protocol (MCP) servers built with the official @modelcontextprotocol/sdk or mcp Python SDK. Detects tools exposing filesystem/shell with user-controlled paths, HTTP/SSE transports without authentication, unvalidated tool arguments, sensitive data leaking via resource URIs, and unbounded tool output that floods context. Invoke when the user asks to "review", "audit", or "scan" an MCP server implementation.
SKILL.md
6.4 KB, as published. Nobody here has run it
MCP Server Security Scan
Defensive scan for MCP servers (@modelcontextprotocol/sdk Node, mcp Python). Reports findings using the shared scoring schema.
Scope
- Files importing
@modelcontextprotocol/sdk/*ormcp(Python) - Tool / resource / prompt registrations (
server.tool,@server.list_tools, etc.) - Transport setup (
StdioServerTransport,SSEServerTransport,StreamableHTTPServerTransport)
Threat model recap
MCP tools execute on the server's trust boundary but are invoked at the LLM's discretion based on a user's conversation. Treat tool arguments as fully attacker-controlled: a user can craft chat input or upload a document that causes the model to call your tool with adversarial arguments (indirect prompt injection).
Rules
| ID | Severity | Detection | Fix |
|---|---|---|---|
| MCP-FS-001 | critical | Tool accepts path / filename argument and reads/writes the filesystem with no containment check (no path.resolve + startsWith(BASE)) | Resolve under a fixed base dir; reject if outside |
| MCP-FS-002 | high | Tool uses fs.readFile/fs.writeFile on a user-supplied path with no allowlist of base directories | Configure allowlist via env; refuse otherwise |
| MCP-EXEC-001 | critical | Tool runs child_process.exec / subprocess.run(shell=True) with command built from arguments | Use spawn with arg array; allowlist commands; reject metacharacters |
| MCP-NET-001 | high | Tool performs HTTP fetch with arbitrary user URL (SSRF: localhost, RFC1918, link-local, AWS metadata 169.254.169.254) | Resolve hostname; block private/link-local/loopback; allowlist if possible |
| MCP-AUTH-001 | critical | HTTP/SSE transport bound to non-loopback interface without authentication middleware (any process on the network can call tools) | Require bearer token / mTLS; bind to loopback unless intentional |
| MCP-AUTH-002 | high | Streamable HTTP transport without Origin/Host validation (DNS rebinding from a victim's browser to localhost MCP) | Validate Origin; require non-browser auth header |
| MCP-VALID-001 | high | Tool registered without input schema (inputSchema / zod / Pydantic) | Define and enforce schema; reject unknown fields |
| MCP-VALID-002 | medium | Tool schema uses additionalProperties: true or z.any() / Dict[str, Any] for arguments | Tighten schema |
| MCP-RES-001 | high | Resource URI scheme allows arbitrary paths (file:///{anything}) without containment | Resolve under a base dir; refuse parent traversal |
| MCP-RES-002 | medium | Resource list includes secrets (.env, key files, ~/.aws) | Apply ignore list comparable to a .gitignore-style filter |
| MCP-OUT-001 | medium | Tool returns unbounded output (full file, full table) that can flood the model's context window — DoS / cost amplification | Cap output size; truncate with explicit notice |
| MCP-OUT-002 | high | Tool output forwards untrusted content (e.g., webpage fetched by tool) without marking provenance | Tag output with source/trust metadata so the calling agent can apply data-vs-instruction framing |
| MCP-LOG-001 | medium | Tool arguments / results logged to disk or remote sink without redaction | Log tool name and run id; redact bodies |
| MCP-PROMPT-001 | medium | Server-defined prompts include literal credentials or per-tenant data | Parameterize via prompt arguments; never bake secrets in |
| MCP-VER-001 | low | Server advertises capabilities it does not implement (e.g., declares tools but registers none) | Match advertised capabilities to implementation |
Wrong vs. right
MCP-FS-001 (path traversal)
// ❌ ../../etc/passwd
server.tool('read_doc', { path: z.string() }, async ({ path }) => {
const text = await fs.readFile(path, 'utf8');
return { content: [{ type: 'text', text }] };
});
// ✅ Containment + allowlist
const BASE = path.resolve(process.env.MCP_DOC_ROOT!);
server.tool(
'read_doc',
{ path: z.string() },
async ({ path: rel }) => {
const target = path.resolve(BASE, rel);
if (!target.startsWith(BASE + path.sep)) throw new Error('forbidden');
const text = await fs.readFile(target, 'utf8');
return { content: [{ type: 'text', text }] };
},
);
MCP-AUTH-001 (HTTP transport without auth)
// ❌ Network-reachable, no auth
const transport = new StreamableHTTPServerTransport({ port: 8080 });
await server.connect(transport);
// ✅ Auth + Origin check + loopback default
const transport = new StreamableHTTPServerTransport({
host: '127.0.0.1',
port: 8080,
requestHook: (req) => {
if (req.headers.authorization !== `Bearer ${process.env.MCP_TOKEN}`) {
throw new Error('unauthorized');
}
const origin = req.headers.origin;
if (origin && !ALLOWED_ORIGINS.has(origin)) throw new Error('bad origin');
},
});
MCP-NET-001 (SSRF)
# ❌ Tool fetches anything
@server.tool()
async def fetch(url: str) -> str:
async with httpx.AsyncClient() as c:
return (await c.get(url)).text
# ✅ DNS resolved + private-range blocked
import ipaddress, socket
BLOCKED = [ipaddress.ip_network(n) for n in
("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"169.254.0.0/16", "::1/128", "fc00::/7", "fe80::/10")]
def safe_host(host: str) -> bool:
for _, _, _, _, sa in socket.getaddrinfo(host, None):
ip = ipaddress.ip_address(sa[0])
if any(ip in n for n in BLOCKED):
return False
return True
@server.tool()
async def fetch(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not safe_host(parsed.hostname):
raise ValueError("forbidden")
...
References
- MCP specification: https://modelcontextprotocol.io/specification
- MCP TypeScript SDK: https://github.com/modelcontextprotocol/typescript-sdk
- MCP Python SDK: https://github.com/modelcontextprotocol/python-sdk
- OWASP SSRF: https://owasp.org/www-community/attacks/Server_Side_Request_Forgery