audit_tool_descriptions
Audit a pasted JSON tool list from your MCP server's tools/list output for injection, poisoning, and SSRF vulnerabilities. Useful for servers behind auth or not deployed.
Instructions
Audit a JSON string containing a tool list (paste from your own MCP server's tools/list output). Same rule catalogue as scan_mcp_url — useful when the server is behind auth or not yet deployed.
tools_json accepts either: a raw list, or {"tools": [...]}, or
{"result": {"tools": [...]}}.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| tools_json | Yes | ||
| api_key | No |
Implementation Reference
- server.py:443-482 (handler)The main handler for the 'audit_tool_descriptions' MCP tool. Accepts a JSON string of tools (from tools/list output) and an optional API key. Parses the JSON, extracts the tool array, runs injection scanning rules via _scan_tools_list(), computes a score/verdict, and returns a structured audit report.
@mcp.tool() def audit_tool_descriptions(tools_json: str, api_key: str = "") -> dict: """ Audit a JSON string containing a tool list (paste from your own MCP server's tools/list output). Same rule catalogue as scan_mcp_url — useful when the server is behind auth or not yet deployed. `tools_json` accepts either: a raw list, or {"tools": [...]}, or {"result": {"tools": [...]}}. """ ok, msg, tier = check_access(api_key) if not ok: return {"error": msg, "upgrade": STRIPE_79} quota_ok, quota_msg = _consume_quota(tier, key=api_key or "anonymous") if not quota_ok: return {"error": quota_msg, "upgrade_pro": STRIPE_29} try: payload = json.loads(tools_json) except json.JSONDecodeError as e: return {"error": f"invalid JSON: {e}", "tier": tier} tools = payload if isinstance(payload, list) else ( payload.get("tools") or (payload.get("result") or {}).get("tools") if isinstance(payload, dict) else None ) if tools is None: return {"error": "no 'tools' array found", "tier": tier} audit = _scan_tools_list(tools) score = _score(audit["severity_counts"]) return { "scanned_at": datetime.now(timezone.utc).isoformat(), "tier": tier, "quota": quota_msg, "score_0_100": score, "verdict": _verdict(score, audit["severity_counts"]), "severity_counts": audit["severity_counts"], "tools_scanned": audit["tools_scanned"], "total_findings": audit["total_findings"], "findings": audit["all_findings"][:100], "rules_applied": len(INJECTION_RULES), } - server.py:443-443 (registration)The tool is registered via the @mcp.tool() decorator on line 443, using the FastMCP instance defined on line 392 (mcp = FastMCP("meok-mcp-injection-scan")), making it available as an MCP tool named 'audit_tool_descriptions'.
@mcp.tool() - server.py:299-326 (helper)The _scan_tools_list() helper function is called by audit_tool_descriptions. It iterates over every tool in the list, runs _scan_text() on the tool name, description, and inputSchema JSON, and aggregates findings with severity counts per tool.
def _scan_tools_list(tools: list) -> dict: """Run rules against every tool in a tools/list MCP response.""" all_findings = [] per_tool = {} if not isinstance(tools, list): return {"error": f"tools must be a list, got {type(tools).__name__}", "findings": []} for t in tools: if not isinstance(t, dict): continue name = t.get("name") or "<unnamed>" desc = t.get("description") or "" schema_text = json.dumps(t.get("inputSchema") or {}, separators=(",", ":")) tool_findings = [] tool_findings.extend(_scan_text(name)) tool_findings.extend(_scan_text(desc)) tool_findings.extend(_scan_text(schema_text)) for f in tool_findings: f["tool"] = name per_tool[name] = tool_findings all_findings.extend(tool_findings) sev_counts = Counter(f["severity"] for f in all_findings) return { "tools_scanned": len(per_tool), "total_findings": len(all_findings), "severity_counts": dict(sev_counts), "per_tool": per_tool, "all_findings": all_findings, } - server.py:239-268 (helper)The _scan_text() helper is called by _scan_tools_list(). It runs all INJECTION_RULES (regex patterns) against a given text chunk and returns a list of finding dicts with rule_id, severity, category, name, evidence, and remediation.
def _scan_text(text: str) -> list[dict]: """Run every rule against a chunk of text. Returns list of finding dicts.""" findings: list[dict] = [] if not text: return findings for rule in INJECTION_RULES: pat = rule["pattern"] if pat == "_long_description": if len(text) > 1024: findings.append({ "rule_id": rule["id"], "severity": rule["severity"], "category": rule["category"], "name": rule["name"], "evidence": f"length={len(text)} chars", "remediation": rule["remediation"], }) continue for m in pat.finditer(text): findings.append({ "rule_id": rule["id"], "severity": rule["severity"], "category": rule["category"], "name": rule["name"], "evidence": (m.group(0)[:80] + "…") if len(m.group(0)) > 80 else m.group(0), "remediation": rule["remediation"], }) # one match per rule per text is enough for the report break return findings - server.py:443-482 (schema)The input schema is defined by the function signature: tools_json (str, required — a JSON string of tools/list output) and api_key (str, optional — for authenticated/pro tier access). The output schema returns a dict with scanned_at, tier, quota, score_0_100, verdict, severity_counts, tools_scanned, total_findings, findings, and rules_applied.
@mcp.tool() def audit_tool_descriptions(tools_json: str, api_key: str = "") -> dict: """ Audit a JSON string containing a tool list (paste from your own MCP server's tools/list output). Same rule catalogue as scan_mcp_url — useful when the server is behind auth or not yet deployed. `tools_json` accepts either: a raw list, or {"tools": [...]}, or {"result": {"tools": [...]}}. """ ok, msg, tier = check_access(api_key) if not ok: return {"error": msg, "upgrade": STRIPE_79} quota_ok, quota_msg = _consume_quota(tier, key=api_key or "anonymous") if not quota_ok: return {"error": quota_msg, "upgrade_pro": STRIPE_29} try: payload = json.loads(tools_json) except json.JSONDecodeError as e: return {"error": f"invalid JSON: {e}", "tier": tier} tools = payload if isinstance(payload, list) else ( payload.get("tools") or (payload.get("result") or {}).get("tools") if isinstance(payload, dict) else None ) if tools is None: return {"error": "no 'tools' array found", "tier": tier} audit = _scan_tools_list(tools) score = _score(audit["severity_counts"]) return { "scanned_at": datetime.now(timezone.utc).isoformat(), "tier": tier, "quota": quota_msg, "score_0_100": score, "verdict": _verdict(score, audit["severity_counts"]), "severity_counts": audit["severity_counts"], "tools_scanned": audit["tools_scanned"], "total_findings": audit["total_findings"], "findings": audit["all_findings"][:100], "rules_applied": len(INJECTION_RULES), }