Skill — MCP security audit via MCTS
When to use
- Before publishing an MCP server (PyPI / npm / Anthropic registry)
- As a CI gate on any repo that exposes MCP tools
- After adding a new MCP tool — detect regressions via baseline
- When the user asks "is my MCP server safe?"
- Before installing a third-party MCP (
mcts vet pypi:<package>)
What MCTS scans
MCTS is a static heuristic analyzer on the MCP server side. It models the following threats:
- Destructive tools without safeguards (
delete,drop,rm,exec…) - Prompt injection via tool descriptions (tool poisoning)
- Attack chains: combinations of innocent tools → exfiltration or execution
- Excessive Agency (OWASP LLM06): overly broad permissions
- Path traversal, command injection, plain-text secrets in tool code
⚠️ The scanner does not read function logic — it flags on names, imports, and structure. Many findings are structural false positives to interpret with care.
Installation and invocation
# Recommended isolated install (does not touch the project venv)
uvx --from mcp-mcts mcts scan <server_path.py>
# Or global install
pipx install mcp-mcts
uv tool install mcp-mcts
PyPI distribution: mcp-mcts (the short name mcts is taken).
Executables provided: mcts and mcts-mcp.
Standard workflow
1. Locate the MCP entrypoint
# Python — look for FastMCP, @mcp.tool, mcp.server
grep -rn "FastMCP\|@mcp.tool\|mcp.server" --include="*.py" <project>
# TypeScript/JS — look for McpServer or createServer
grep -rn "McpServer\|@modelcontextprotocol/sdk/server" --include="*.ts" <project>
2. First scan
cd <project> && uvx --from mcp-mcts mcts scan <entrypoint> \
--format json --output /tmp/mcts-<project>.json
HTML/SARIF reports are written to <project>/mcts_analysis/ (add to .gitignore).
3. Read the score
jq '{summary, score, score_v2: {absolute_risk: .score_v2.absolute_risk, security_score: .score_v2.security_score, risk_percentile: .score_v2.risk_percentile, dimension_scores: .score_v2.dimension_scores}}' /tmp/mcts-<project>.json
4. Detail critical and high findings
jq '.findings[] | select(.severity == "critical")' /tmp/mcts-<project>.json
jq '.findings[] | select(.severity == "high")' /tmp/mcts-<project>.json
5. Capture baseline (for future regression detection)
uvx --from mcp-mcts mcts scan <entrypoint> --save-baseline mcts_baseline.json
Commit the baseline. Future scans use --baseline mcts_baseline.json to report only new findings.
Interpreting the two scores
| Metric | Reading |
|---|---|
| Overall (legacy) /100 | School-grade score weighted by severity × count |
| Risk Index (legacy) /100 | Inverse — higher is worse |
| Absolute Risk (v2) | Absolute risk summed and weighted across 8 dimensions |
| Security Score (v2) /100 | Benchmark vs public corpus (corpus-YYYY-MM) |
| Risk percentile | "Riskier than X% of MCP servers in the corpus" |
The 8 dimensions (score_v2)
| Dimension | Meaning |
|---|---|
| reachability | How accessible the tool is from the agent |
| blast_radius | Damage scope if exploited |
| business_impact | Importance of data/systems touched |
| exploitability | Ease of exploitation (trivial names, no guard) |
| asset_value | Value of what is protected |
| attack_preconditions | Weak preconditions = high risk |
| exposure | Exposure surface (local-only vs networked) |
| threat_maturity | Maturity of the MCP attack ecosystem |
For a local-only single-user MCP, reachability=98 and blast_radius=100 are structurally high — what matters is exploitability and specific findings.
Common findings and pragmatic mitigations
| Finding | Pattern | Mitigation |
|---|---|---|
| MCTS-T-1006 Destructive tool | Tool named delete/drop/reset |
Mandatory confirm_id parameter that must equal the id to delete |
| MCTS-T-1005 Read→exfil chain | search/get/read tools + import/send/upload tools |
Whitelist paths (urlparse.scheme not in URL_SCHEMES), require absolute local paths |
| MCTS-T-1002 Path traversal | File-access tool with filepath param |
Validate and canonicalize paths; restrict to an allowlisted root |
| MCTS-T-1001 Excessive description length | Very long docstring on a tool | Shorten the @mcp.tool() docstring (move details elsewhere) |
| MCTS-T-1001 Security-sensitive ops | Tool performs disk or network I/O | Often false positive if I/O is strictly local and necessary — document as accepted |
| behavioral-sink-*-urllib-open-fs | urllib/pathlib/open imports in the server module |
Frequent false positive — isolate I/O utilities into a separate module to avoid contaminating tools |
| meta-excessive-desc-tool: | Tool with description over threshold | Same as T-1001, shorten |
Recommended helper (Python — local path validation)
from pathlib import Path
from urllib.parse import urlparse
def _validate_local_path(path: str) -> str | None:
"""Return an error message if path is not safe, otherwise None."""
parsed = urlparse(path)
if parsed.scheme and parsed.scheme not in ("", "file"):
return f"scheme '{parsed.scheme}' not allowed"
p = Path(path).expanduser()
if not p.is_absolute():
return f"relative path rejected: {path}"
if not p.exists():
return f"path not found: {p}"
return None
Apply at the top of every tool that accepts a path: str parameter (import, upload, file read).
Recommended helper (delete with confirmation)
@mcp.tool()
def delete(id: int, confirm_id: int) -> dict:
"""Delete a record. `confirm_id` must equal `id` to confirm."""
if confirm_id != id:
return {"error": f"confirmation required: confirm_id ({confirm_id}) != id ({id})"}
return _service.delete(id)
Known scanner limitations
- Does not read function logic:
delete(id, confirm_id)still flags asMCTS-T-1006even after adding the guard — heuristic purely based on tool name - Attack chain false positives:
search+import_local_fileis flagged asread→exfileven when the import is strictly local behavioral-sink-*-urllib-open-fsappears on all tools if the server module importsurllib/pathlib/open, by structural contamination
Consequence: the raw score does not improve with fixes — the baseline is the relevant tool for catching regressions (new tools, new attack chains).
Suggested CI gate
# .github/workflows/mcts.yml
- name: MCTS audit
run: |
uvx --from mcp-mcts mcts scan src/<package>/mcp/server.py \
--baseline mcts_baseline.json --max-absolute-risk 700
--max-absolute-risk fails the build if the risk exceeds the threshold. Tune to project profile (personal local-only: 700 OK; multi-user enterprise: aim for 200).
Report template
## MCTS audit — <project> (<date>)
**Score**: overall <X>/100 · absolute_risk <Y> (<level>) · security <Z>/100 · <percentile>th percentile
**Critical (<n>)**
- <finding_id>: <title> — <pragmatic mitigation>
**Significant medium (<n>)**
- ... (filter out structural false positives)
**Identified false positives**
- behavioral-sink-* × <n>: contamination by urllib/pathlib imports — accepted
- ...
**Concrete actions**
1. <code fix> — file:line
2. <code fix>
3. Capture baseline `mcts_baseline.json`
**Accepted residual risk**
- <finding_id>: why we accept it (local-only context, single-user…)
See also
- MCTS repo: https://github.com/MCP-Audit/MCTS
- OWASP LLM Top 10 (mapping included in reports)
- OWASP MCP Top 10 (mapping included)
- Complementary skill:
audit-securite(general web app security, not MCP-specific)