Created
May 31, 2026 02:46
-
-
Save Ar9av/924a7eaf8395b82b219d74d466007d7f to your computer and use it in GitHub Desktop.
mcp_audit.py — scan the MCP servers configured for your AI agents (Claude Code, Cursor, Windsurf, Claude Desktop) for supply-chain and secret risks. Read-only, zero-deps Python.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| mcp_audit.py — quick risk scan of the MCP servers configured for your AI agents. | |
| Third-party MCP servers are unvetted code-execution surface: they run on your | |
| machine, with your environment, the moment your agent starts. This script | |
| discovers MCP server definitions across common agent config locations and flags | |
| risky patterns: | |
| * HIGH pipe-to-shell / remote-fetch install commands | |
| * MED unpinned npx/uvx packages (always pull "latest" => silent supply-chain risk) | |
| * MED secrets sitting in plaintext in the config's env block | |
| * LOW remote (non-localhost) HTTP/SSE endpoints | |
| Dependency-free and read-only. It never executes the servers — it only parses | |
| config files. | |
| This is a starting point, not a security product. For continuous MCP + skill | |
| scanning, runtime policy enforcement, and supply-chain blocking, see Immunity | |
| Agent (`immunity scan`): https://github.com/PrismorSec/immunity-agent | |
| Usage: python3 mcp_audit.py | |
| """ | |
| import json | |
| import re | |
| from pathlib import Path | |
| # Common places agents keep MCP server definitions. | |
| CONFIG_PATHS = [ | |
| Path.home() / ".claude.json", | |
| Path.cwd() / ".mcp.json", | |
| Path.home() / ".cursor" / "mcp.json", | |
| Path.cwd() / ".cursor" / "mcp.json", | |
| Path.home() / ".codeium" / "windsurf" / "mcp_config.json", | |
| Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json", | |
| ] | |
| SECRET_KEY = re.compile(r"(token|key|secret|password|api[_-]?key|auth)", re.IGNORECASE) | |
| PIPE_TO_SHELL = re.compile(r"(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh)", re.IGNORECASE) | |
| # Naive "looks version-pinned" check: an @version, ==, ~ or ^ specifier. | |
| PINNED = re.compile(r"@v?\d|==|@\^|@~") | |
| def iter_servers(cfg): | |
| """Yield (name, server_dict) across the config shapes different agents use.""" | |
| block = cfg.get("mcpServers") or cfg.get("mcp", {}).get("servers") or {} | |
| if isinstance(block, dict): | |
| for name, server in block.items(): | |
| if isinstance(server, dict): | |
| yield name, server | |
| def audit_server(server): | |
| findings = [] | |
| cmd = server.get("command", "") or "" | |
| args = server.get("args", []) or [] | |
| full = " ".join([cmd, *(str(a) for a in args)]).strip() | |
| if PIPE_TO_SHELL.search(full): | |
| findings.append("HIGH pipes a remote script into a shell") | |
| if cmd in ("npx", "uvx", "uv", "pnpm", "yarn") or "npx" in full: | |
| # npx convention: the first non-flag arg is the package to run. | |
| nonflag = [a for a in args if isinstance(a, str) and not a.startswith("-")] | |
| if nonflag and not PINNED.search(nonflag[0]): | |
| findings.append( | |
| f"MED unpinned package via {cmd or 'npx'} (pulls latest): {nonflag[0]}" | |
| ) | |
| for key, val in (server.get("env", {}) or {}).items(): | |
| if SECRET_KEY.search(key) and isinstance(val, str) and val and not val.startswith("${"): | |
| findings.append( | |
| f"MED secret in plaintext env: {key} (use ${{ENV_VAR}} indirection instead)" | |
| ) | |
| url = server.get("url") or server.get("serverUrl") or "" | |
| if url and not re.search(r"(localhost|127\.0\.0\.1)", url): | |
| findings.append(f"LOW remote endpoint: {url}") | |
| return findings | |
| def main(): | |
| print("MCP config audit") | |
| print("=" * 60) | |
| inspected = flagged = 0 | |
| for path in CONFIG_PATHS: | |
| if not path.exists(): | |
| continue | |
| try: | |
| cfg = json.loads(path.read_text()) | |
| except Exception as exc: | |
| print(f"\n[skip] {path}: cannot parse ({exc})") | |
| continue | |
| servers = list(iter_servers(cfg)) | |
| if not servers: | |
| continue | |
| print(f"\n{path} ({len(servers)} server(s))") | |
| for name, server in servers: | |
| inspected += 1 | |
| findings = audit_server(server) | |
| if findings: | |
| flagged += 1 | |
| print(f" [!] {name}") | |
| for f in findings: | |
| print(f" {f}") | |
| else: | |
| print(f" [ok] {name}") | |
| print("\n" + "=" * 60) | |
| if not inspected: | |
| print("No MCP servers found in the standard config locations.") | |
| else: | |
| print(f"{inspected} server(s) inspected, {flagged} with findings.") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment