mcp-runtime-audit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcp_audit/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """mcp-audit: local runtime security scanner for MCP servers."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
mcp_audit/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ """mcp-audit CLI: scan local MCP configs and optionally fix them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .scanners import (
11
+ agent_guard_link,
12
+ apply_fixes,
13
+ discover_configs,
14
+ load_config,
15
+ plan_fixes,
16
+ run_all_scans,
17
+ )
18
+
19
+ EXIT_OK = 0
20
+ EXIT_FINDINGS = 1
21
+ EXIT_ERROR = 2
22
+
23
+
24
+ def _parse_args(argv):
25
+ parser = argparse.ArgumentParser(
26
+ prog="mcp-audit",
27
+ description="Local runtime security scanner for MCP servers.",
28
+ )
29
+ parser.add_argument("--version", action="version", version=__version__)
30
+ sub = parser.add_subparsers(dest="command", required=True)
31
+
32
+ scan = sub.add_parser("scan", help="scan MCP configs for security issues")
33
+ scan.add_argument("--config", action="append", dest="configs",
34
+ metavar="PATH", default=[],
35
+ help="config file to scan (repeatable; defaults to "
36
+ "~/.claude.json and other known locations)")
37
+ scan.add_argument("--format", choices=["text", "json"], default="text",
38
+ help="output format")
39
+ scan.add_argument("--probe", action="store_true",
40
+ help="send one unauthenticated GET to each http/sse "
41
+ "endpoint to confirm reachability")
42
+ scan.add_argument("--timeout", type=float, default=4.0,
43
+ help="probe timeout in seconds (default 4.0)")
44
+ scan.add_argument("--link-agent-guard", action="store_true",
45
+ help="write critical servers into agent-guard's "
46
+ "blocklist when it is installed")
47
+
48
+ fix = sub.add_parser("fix", help="print or apply one-click fixes")
49
+ fix.add_argument("--config", action="append", dest="configs",
50
+ metavar="PATH", default=[],
51
+ help="config file to fix (repeatable)")
52
+ fix.add_argument("--apply", action="store_true",
53
+ help="write the fixes to the config file (backs up "
54
+ "first); without it only prints the plan")
55
+ return parser.parse_args(argv)
56
+
57
+
58
+ def _load_all(paths):
59
+ servers, errors = [], []
60
+ for path in paths:
61
+ svcs, err = load_config(path)
62
+ if err:
63
+ errors.append(err)
64
+ servers.extend(svcs)
65
+ return servers, errors
66
+
67
+
68
+ def _format_text(findings, errors, n_servers, n_configs):
69
+ lines = []
70
+ if errors:
71
+ for e in errors:
72
+ lines.append("error: %s" % e)
73
+ if not findings:
74
+ lines.append("mcp-audit: %d server(s) in %d config(s): no findings"
75
+ % (n_servers, n_configs))
76
+ return "\n".join(lines)
77
+ sev_max = findings[0].severity
78
+ lines.append("mcp-audit: %d finding(s) across %d server(s) "
79
+ "(worst: %s):" % (len(findings), n_servers, sev_max))
80
+ for f in findings:
81
+ cve = " [%s]" % f.cve_id if f.cve_id else ""
82
+ lines.append("[%s]%s %s (%s): %s"
83
+ % (f.severity.upper(), cve, f.rule_id, f.server, f.evidence))
84
+ lines.append(" fix: %s" % f.fix)
85
+ return "\n".join(lines)
86
+
87
+
88
+ def cmd_scan(args):
89
+ paths = discover_configs(args.configs)
90
+ if not paths:
91
+ msg = {"findings": [], "errors": ["no config files found"],
92
+ "summary": {"servers": 0, "configs": 0}}
93
+ if args.format == "json":
94
+ print(json.dumps(msg, indent=2))
95
+ else:
96
+ print("mcp-audit: no MCP config files found "
97
+ "(checked ~/.claude.json, ~/.codex/config.json, "
98
+ "~/.config/mcp/servers.json, ~/.mcp.json); "
99
+ "pass --config PATH to scan a specific file")
100
+ return EXIT_ERROR
101
+ servers, errors = _load_all(paths)
102
+ findings = run_all_scans(servers, probe=args.probe, timeout=args.timeout)
103
+ if args.link_agent_guard and findings:
104
+ linked, msg = agent_guard_link(findings)
105
+ if args.format == "json":
106
+ pass # message folded into output below
107
+ else:
108
+ print(msg)
109
+ if args.format == "json":
110
+ print(json.dumps({
111
+ "findings": [f.to_dict() for f in findings],
112
+ "errors": errors,
113
+ "summary": {
114
+ "servers": len(servers),
115
+ "configs": len(paths),
116
+ "findings": len(findings),
117
+ "worst": findings[0].severity if findings else None,
118
+ },
119
+ }, indent=2))
120
+ else:
121
+ print(_format_text(findings, errors, len(servers), len(paths)))
122
+ if errors and not servers:
123
+ return EXIT_ERROR
124
+ return EXIT_FINDINGS if findings else EXIT_OK
125
+
126
+
127
+ def cmd_fix(args):
128
+ paths = discover_configs(args.configs)
129
+ if not paths:
130
+ print("mcp-audit: no MCP config files found; pass --config PATH")
131
+ return EXIT_ERROR
132
+ rc = EXIT_OK
133
+ for path in paths:
134
+ servers, errors = _load_all([path])
135
+ findings = run_all_scans(servers)
136
+ plan, missing = plan_fixes(path, findings) if not errors else ([], [])
137
+ if args.apply:
138
+ if errors:
139
+ print("%s: error: %s" % (path, errors[0]))
140
+ rc = EXIT_ERROR
141
+ continue
142
+ if not plan:
143
+ print("%s: nothing fixable" % path)
144
+ continue
145
+ backup, applied = apply_fixes(path, findings)
146
+ print("%s: applied %d fix(es); backup at %s"
147
+ % (path, applied, backup))
148
+ else:
149
+ print("%s:" % path)
150
+ if not plan and not missing:
151
+ print(" nothing fixable")
152
+ for item in plan:
153
+ print(" - [%s] %s: %s" % (
154
+ item["rule_id"], item["server"], item["action"]))
155
+ for name in missing:
156
+ print(" - %s: server not found in config (skipped)" % name)
157
+ if plan:
158
+ print(" re-run with --apply to write these fixes "
159
+ "(config is backed up first)")
160
+ return rc
161
+
162
+
163
+ def main(argv=None):
164
+ args = _parse_args(argv if argv is not None else sys.argv[1:])
165
+ if args.command == "scan":
166
+ return cmd_scan(args)
167
+ if args.command == "fix":
168
+ return cmd_fix(args)
169
+ return EXIT_ERROR
170
+
171
+
172
+ if __name__ == "__main__":
173
+ sys.exit(main())
mcp_audit/cve_db.py ADDED
@@ -0,0 +1,215 @@
1
+ """Bundled known-CVE table for MCP servers and MCP-adjacent tooling.
2
+
3
+ This is a best-effort advisory snapshot (bundled 2026-10). Affected
4
+ version ranges come from public advisories; re-check NVD / vendor
5
+ releases before using these for compliance reporting.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ CVE_DB = [
11
+ {
12
+ "id": "CVE-2025-49596",
13
+ "title": "MCP Inspector proxy unauthenticated RCE",
14
+ "cvss": 9.4,
15
+ "severity": "critical",
16
+ "products": ["@modelcontextprotocol/inspector", "mcp-inspector"],
17
+ "affected": "<0.14.1",
18
+ "fixed_in": "0.14.1",
19
+ "description": (
20
+ "The MCP Inspector proxy (default port 6277) accepted an "
21
+ "unauthenticated request to /sse with transportType=stdio and "
22
+ "arbitrary command/args, spawning the command as a local "
23
+ "subprocess. A malicious web page could drive it cross-origin "
24
+ "(0.0.0.0-day / DNS rebinding), so visiting a page was enough "
25
+ "to execute code on the developer's machine."
26
+ ),
27
+ "references": [
28
+ "https://nvd.nist.gov/vuln/detail/CVE-2025-49596",
29
+ "https://github.com/modelcontextprotocol/inspector/security/advisories/GHSA-7f8r-222p-6f5g",
30
+ ],
31
+ },
32
+ {
33
+ "id": "CVE-2026-76404",
34
+ "title": "Splunk MCP Server unsafe deserialization RCE",
35
+ "cvss": 9.1,
36
+ "severity": "critical",
37
+ "products": ["splunk-mcp-server", "@splunk/mcp-server"],
38
+ "affected": "<1.2.1",
39
+ "fixed_in": "1.2.1",
40
+ "description": (
41
+ "The Splunk MCP Server app's credential-management component "
42
+ "deserialized stored data without type validation (CWE-502). "
43
+ "A user holding the Splunk admin role could execute arbitrary "
44
+ "OS commands on the host. Splunk advisory SVD-2026-0808."
45
+ ),
46
+ "references": [
47
+ "https://nvd.nist.gov/vuln/detail/CVE-2026-76404",
48
+ ],
49
+ },
50
+ {
51
+ "id": "CVE-2026-58201",
52
+ "title": "Lokka Microsoft 365 MCP Server SSRF",
53
+ "cvss": None,
54
+ "severity": "high",
55
+ "products": ["lokka", "lokka-microsoft"],
56
+ "affected": "<2.1.2",
57
+ "fixed_in": "2.1.2",
58
+ "description": (
59
+ "Lokka's Lokka-Microsoft tool built Azure Resource Manager "
60
+ "request URLs by concatenating a user-supplied path onto the "
61
+ "management.azure.com base string (CWE-918). A crafted path "
62
+ "could redirect a valid ARM Bearer token to an "
63
+ "attacker-controlled host."
64
+ ),
65
+ "references": [
66
+ "https://nvd.nist.gov/vuln/detail/CVE-2026-58201",
67
+ ],
68
+ },
69
+ {
70
+ "id": "CVE-2026-105697",
71
+ "title": "Langflow MCP stdio transport RCE (bash -c wrapper)",
72
+ "cvss": 9.9,
73
+ "severity": "critical",
74
+ "products": ["langflow", "langflow-base", "lfx"],
75
+ "affected": "<1.10.3",
76
+ "fixed_in": "1.10.3",
77
+ "description": (
78
+ "Before 1.10.3 the MCP stdio transport launched whatever "
79
+ "command/args a user put in an MCP server configuration with "
80
+ "no allowlist, wrapped in `bash -c \"exec {command} ...\"` "
81
+ "(CWE-78). With the default LANGFLOW_AUTO_LOGIN=true, an "
82
+ "unauthenticated attacker could add a malicious MCP server and "
83
+ "get remote code execution on the Langflow host."
84
+ ),
85
+ "references": [
86
+ "https://nvd.nist.gov/vuln/detail/CVE-2026-105697",
87
+ ],
88
+ },
89
+ {
90
+ "id": "CVE-2026-89039",
91
+ "title": "MCP server RCE (advisory, see NVD for product mapping)",
92
+ "cvss": None,
93
+ "severity": "high",
94
+ "products": [],
95
+ "affected": None,
96
+ "fixed_in": None,
97
+ "description": (
98
+ "Known CVE in the MCP ecosystem. Product/version mapping not "
99
+ "bundled in this snapshot; check the NVD entry and vendor "
100
+ "advisories for affected versions."
101
+ ),
102
+ "references": ["https://nvd.nist.gov/vuln/detail/CVE-2026-89039"],
103
+ },
104
+ {
105
+ "id": "CVE-2026-105793",
106
+ "title": "MCP server vulnerability (advisory, see NVD for product mapping)",
107
+ "cvss": None,
108
+ "severity": "high",
109
+ "products": [],
110
+ "affected": None,
111
+ "fixed_in": None,
112
+ "description": (
113
+ "Known CVE in the MCP ecosystem. Product/version mapping not "
114
+ "bundled in this snapshot; check the NVD entry and vendor "
115
+ "advisories for affected versions."
116
+ ),
117
+ "references": ["https://nvd.nist.gov/vuln/detail/CVE-2026-105793"],
118
+ },
119
+ {
120
+ "id": "CVE-2026-94486",
121
+ "title": "MCP server vulnerability (advisory, see NVD for product mapping)",
122
+ "cvss": None,
123
+ "severity": "high",
124
+ "products": [],
125
+ "affected": None,
126
+ "fixed_in": None,
127
+ "description": (
128
+ "Known CVE in the MCP ecosystem. Product/version mapping not "
129
+ "bundled in this snapshot; check the NVD entry and vendor "
130
+ "advisories for affected versions."
131
+ ),
132
+ "references": ["https://nvd.nist.gov/vuln/detail/CVE-2026-94486"],
133
+ },
134
+ {
135
+ "id": "CVE-2026-19807",
136
+ "title": "MCP server vulnerability (advisory, see NVD for product mapping)",
137
+ "cvss": None,
138
+ "severity": "high",
139
+ "products": [],
140
+ "affected": None,
141
+ "fixed_in": None,
142
+ "description": (
143
+ "Known CVE in the MCP ecosystem. Product/version mapping not "
144
+ "bundled in this snapshot; check the NVD entry and vendor "
145
+ "advisories for affected versions."
146
+ ),
147
+ "references": ["https://nvd.nist.gov/vuln/detail/CVE-2026-19807"],
148
+ },
149
+ ]
150
+
151
+
152
+ def get_cve(cve_id: str):
153
+ """Return the CVE entry for *cve_id*, or None."""
154
+ for entry in CVE_DB:
155
+ if entry["id"] == cve_id:
156
+ return entry
157
+ return None
158
+
159
+
160
+ def _version_tuple(version: str):
161
+ """Convert a version string to a comparable tuple of ints.
162
+
163
+ Non-numeric parts are dropped, so "1.10.3a" -> (1, 10, 3).
164
+ """
165
+ parts = []
166
+ for chunk in str(version).split("."):
167
+ digits = "".join(c for c in chunk if c.isdigit())
168
+ if digits:
169
+ parts.append(int(digits))
170
+ else:
171
+ parts.append(0)
172
+ return tuple(parts)
173
+
174
+
175
+ def version_matches_affected(version: str, affected: str) -> bool:
176
+ """Evaluate a constraint like '<0.14.1' against a version string."""
177
+ for op in ("<=", ">=", "==", "<", ">"):
178
+ if affected.startswith(op):
179
+ bound = affected[len(op):].strip()
180
+ v, b = _version_tuple(version), _version_tuple(bound)
181
+ # Pad shorter tuple so (1, 2) compares sanely with (1, 2, 1).
182
+ width = max(len(v), len(b))
183
+ v += (0,) * (width - len(v))
184
+ b += (0,) * (width - len(b))
185
+ if op == "<":
186
+ return v < b
187
+ if op == "<=":
188
+ return v <= b
189
+ if op == ">":
190
+ return v > b
191
+ if op == ">=":
192
+ return v >= b
193
+ return v == b
194
+ raise ValueError("unsupported constraint: %r" % affected)
195
+
196
+
197
+ def affected_cves(product: str, version: str):
198
+ """Return CVE entries whose product list contains *product* and whose
199
+ version range covers *version*. Matching is case-insensitive."""
200
+ hits = []
201
+ if not product or not version:
202
+ return hits
203
+ name = product.strip().lower()
204
+ for entry in CVE_DB:
205
+ if entry["affected"] is None:
206
+ continue
207
+ aliases = [p.lower() for p in entry.get("products", [])]
208
+ if name not in aliases:
209
+ continue
210
+ try:
211
+ if version_matches_affected(version, entry["affected"]):
212
+ hits.append(entry)
213
+ except (ValueError, TypeError):
214
+ continue
215
+ return hits
mcp_audit/scanners.py ADDED
@@ -0,0 +1,502 @@
1
+ """Config discovery, parsing, and security scanners for mcp-audit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import socket
9
+ import time
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from dataclasses import dataclass, field
14
+
15
+ from .cve_db import affected_cves
16
+
17
+ # ---------------------------------------------------------------- config I/O
18
+
19
+ DEFAULT_CONFIG_PATHS = [
20
+ os.path.expanduser("~/.claude.json"),
21
+ os.path.expanduser("~/.codex/config.json"),
22
+ os.path.expanduser("~/.config/mcp/servers.json"),
23
+ os.path.expanduser("~/.mcp.json"),
24
+ ]
25
+
26
+
27
+ @dataclass
28
+ class McpServer:
29
+ """One server entry parsed from a config file."""
30
+
31
+ name: str
32
+ transport: str # "stdio" | "sse" | "http" | "unknown"
33
+ command: str = ""
34
+ args: list = field(default_factory=list)
35
+ env: dict = field(default_factory=dict)
36
+ url: str = ""
37
+ headers: dict = field(default_factory=dict)
38
+ version: str = ""
39
+ package: str = ""
40
+ source_file: str = ""
41
+
42
+
43
+ @dataclass
44
+ class Finding:
45
+ rule_id: str
46
+ cve_id: str
47
+ severity: str # critical | high | medium | low | info
48
+ server: str
49
+ evidence: str
50
+ fix: str
51
+ source_file: str = ""
52
+
53
+ def to_dict(self):
54
+ return {
55
+ "rule_id": self.rule_id,
56
+ "cve_id": self.cve_id,
57
+ "severity": self.severity,
58
+ "server": self.server,
59
+ "evidence": self.evidence,
60
+ "fix": self.fix,
61
+ "source_file": self.source_file,
62
+ }
63
+
64
+
65
+ def _servers_from_entry(name, entry, source_file=""):
66
+ srv = McpServer(name=name, transport="unknown", source_file=source_file)
67
+ if not isinstance(entry, dict):
68
+ return srv
69
+ srv.command = str(entry.get("command", "") or "")
70
+ args = entry.get("args", [])
71
+ srv.args = [str(a) for a in args] if isinstance(args, list) else []
72
+ env = entry.get("env", {})
73
+ srv.env = dict(env) if isinstance(env, dict) else {}
74
+ srv.url = str(entry.get("url", "") or "")
75
+ headers = entry.get("headers", {})
76
+ srv.headers = dict(headers) if isinstance(headers, dict) else {}
77
+ srv.version = str(entry.get("version", "") or "")
78
+
79
+ if srv.url:
80
+ parsed = urllib.parse.urlparse(srv.url)
81
+ if parsed.scheme in ("http", "https"):
82
+ srv.transport = "sse" if parsed.path.endswith("/sse") or "sse" in parsed.path else "http"
83
+ elif srv.command:
84
+ srv.transport = "stdio"
85
+
86
+ srv.package, srv.version = _infer_package(srv) or (srv.package, srv.version or "")
87
+ return srv
88
+
89
+
90
+ _PKG_RE = re.compile(r"^([@a-zA-Z0-9_.\-/]+?)(?:@([0-9][0-9A-Za-z.\-]*))?$")
91
+
92
+
93
+ def _infer_package(srv):
94
+ """Guess (package, version) from an npx/uvx/node invocation.
95
+
96
+ Handles args like ["-y", "@modelcontextprotocol/inspector@0.13.0"]
97
+ or ["mcp-server-foo@1.2.3", ...].
98
+ """
99
+ tokens = [srv.command] + list(srv.args)
100
+ for tok in tokens:
101
+ tok = tok.strip()
102
+ if tok.startswith("-"):
103
+ continue
104
+ m = _PKG_RE.match(tok)
105
+ if not m:
106
+ continue
107
+ name, ver = m.group(1), m.group(2)
108
+ if "/" in name or name.startswith("@") or re.fullmatch(r"[a-zA-Z0-9_.\-]+", name):
109
+ if ver:
110
+ return name, ver
111
+ return None
112
+
113
+
114
+ def _walk_mcp_servers(obj, source_file=""):
115
+ """Extract mcpServers dicts from a parsed JSON config."""
116
+ found = []
117
+ if isinstance(obj, dict):
118
+ if "mcpServers" in obj and isinstance(obj["mcpServers"], dict):
119
+ for name, entry in obj["mcpServers"].items():
120
+ found.append(_servers_from_entry(name, entry, source_file))
121
+ for key in ("servers", "mcp_servers"):
122
+ if key in obj and isinstance(obj[key], dict):
123
+ for name, entry in obj[key].items():
124
+ found.append(_servers_from_entry(name, entry, source_file))
125
+ # Recurse into nested dicts (e.g. Claude Code's per-project blocks
126
+ # inside ~/.claude.json) to avoid missing nested mcpServers.
127
+ for key, value in obj.items():
128
+ if key in ("mcpServers", "servers", "mcp_servers"):
129
+ continue
130
+ if isinstance(value, dict):
131
+ found.extend(_walk_mcp_servers(value, source_file))
132
+ return found
133
+
134
+
135
+ def load_config(path):
136
+ """Load one config file, return (servers, error_message)."""
137
+ try:
138
+ with open(path, encoding="utf-8") as fh:
139
+ data = json.load(fh)
140
+ except (OSError, json.JSONDecodeError) as exc:
141
+ return [], "cannot read %s: %s" % (path, exc)
142
+ return _walk_mcp_servers(data, path), ""
143
+
144
+
145
+ def discover_configs(explicit=None):
146
+ """Return list of config paths: explicit ones, else existing defaults."""
147
+ if explicit:
148
+ return list(explicit)
149
+ return [p for p in DEFAULT_CONFIG_PATHS if os.path.isfile(p)]
150
+
151
+ # ------------------------------------------------------------ rule 1: stdio
152
+
153
+ # Shell-wrapper patterns that turn a stdio config into command injection.
154
+ SHELL_PATTERNS = [
155
+ (re.compile(r"(^|\s)bash\s+-c(\s|$)", re.I), "bash -c"),
156
+ (re.compile(r"(^|\s)sh\s+-c(\s|$)", re.I), "sh -c"),
157
+ (re.compile(r"(^|\s)zsh\s+-c(\s|$)", re.I), "zsh -c"),
158
+ (re.compile(r"(^|\s)(cmd|powershell|pwsh)(\.exe)?\s+(/[cC]|-c|-Command)(\s|$)"), "cmd /c"),
159
+ (re.compile(r"&&|\|\||;\s*(curl|wget|rm|chmod|nc|ncat)\b", re.I), "command chaining"),
160
+ ]
161
+
162
+
163
+ def scan_dangerous_stdio(servers):
164
+ """Flag stdio servers whose command/args wrap execution in a shell."""
165
+ findings = []
166
+ for srv in servers:
167
+ if srv.transport != "stdio":
168
+ continue
169
+ invocation = " ".join([srv.command] + list(srv.args))
170
+ for pattern, label in SHELL_PATTERNS:
171
+ if pattern.search(invocation):
172
+ findings.append(
173
+ Finding(
174
+ rule_id="STDIO-SHELL",
175
+ cve_id="CVE-2026-105697",
176
+ severity="high",
177
+ server=srv.name,
178
+ evidence="stdio server %r runs through shell wrapper %s: %s"
179
+ % (srv.name, label, invocation[:200]),
180
+ fix="Remove the shell wrapper: set command to the real "
181
+ "executable and pass each argument separately in args. "
182
+ "Never pass untrusted input into a shell -c string.",
183
+ source_file=srv.source_file,
184
+ )
185
+ )
186
+ break
187
+ return findings
188
+
189
+ # ------------------------------------------------- rule 2: unauth endpoints
190
+
191
+
192
+ def _probe_endpoint(url, timeout=4.0):
193
+ """Send one unauthenticated GET. Returns (reachable, status, detail).
194
+
195
+ reachable=True means the endpoint answered *without* any credentials —
196
+ the unauthenticated-reachability signal this scanner is built to detect.
197
+ """
198
+ req = urllib.request.Request(url, headers={"User-Agent": "mcp-audit/0.1.0"})
199
+ try:
200
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
201
+ return True, resp.status, "HTTP %s" % resp.status
202
+ except urllib.error.HTTPError as exc:
203
+ if exc.code in (401, 403, 407):
204
+ return False, exc.code, "HTTP %s (auth required)" % exc.code
205
+ return True, exc.code, "HTTP %s (reachable without auth)" % exc.code
206
+ except urllib.error.URLError as exc:
207
+ return False, 0, "unreachable: %s" % exc.reason
208
+ except (socket.timeout, OSError) as exc:
209
+ return False, 0, "unreachable: %s" % exc
210
+
211
+
212
+ def scan_unauthenticated_endpoints(servers, timeout=4.0, probe=False):
213
+ """Flag http/sse servers reachable with no authentication.
214
+
215
+ probe=False (default): static config check — flags non-loopback URLs
216
+ with no Authorization/Api-Key header. probe=True: also sends one
217
+ unauthenticated GET to confirm the endpoint answers.
218
+ """
219
+ findings = []
220
+ for srv in servers:
221
+ if srv.transport not in ("sse", "http") or not srv.url:
222
+ continue
223
+ host = urllib.parse.urlparse(srv.url).hostname or ""
224
+ has_auth = any(
225
+ k.lower() in ("authorization", "x-api-key", "x-auth-token", "cookie")
226
+ for k in srv.headers
227
+ )
228
+ evidence_bits = []
229
+ if probe:
230
+ reachable, status, detail = _probe_endpoint(srv.url, timeout=timeout)
231
+ if reachable:
232
+ evidence_bits.append("unauthenticated GET -> %s" % detail)
233
+ else:
234
+ reachable = host not in ("", "127.0.0.1", "::1", "localhost")
235
+ evidence_bits.append("no auth header configured")
236
+ if reachable and not has_auth:
237
+ findings.append(
238
+ Finding(
239
+ rule_id="UNAUTH-ENDPOINT",
240
+ cve_id="CVE-2025-49596",
241
+ severity="critical" if probe else "high",
242
+ server=srv.name,
243
+ evidence="server %r exposes %s: %s"
244
+ % (srv.name, srv.url, "; ".join(evidence_bits)),
245
+ fix="Require authentication: put the endpoint behind an "
246
+ "auth proxy or add headers={\"Authorization\": \"Bearer ${MCP_TOKEN}\"} "
247
+ "to the config and rotate the token.",
248
+ source_file=srv.source_file,
249
+ )
250
+ )
251
+ return findings
252
+
253
+ # ------------------------------------------------------ rule 3: bind address
254
+
255
+ BIND_RE = re.compile(r"--?(?:bind|host|listen)(?:-address)?[= ]\s*([0-9a-zA-Z.:]+)")
256
+ PUBLIC_HOSTS = {"0.0.0.0", "::"}
257
+
258
+
259
+ def scan_bind_address(servers):
260
+ """Flag servers bound to all interfaces (0.0.0.0 / ::)."""
261
+ findings = []
262
+ for srv in servers:
263
+ hits = []
264
+ if srv.url:
265
+ host = urllib.parse.urlparse(srv.url).hostname or ""
266
+ if host in PUBLIC_HOSTS:
267
+ hits.append("url host %s" % host)
268
+ invocation = " ".join([srv.command] + list(srv.args))
269
+ for m in BIND_RE.finditer(invocation):
270
+ if m.group(1) in PUBLIC_HOSTS:
271
+ hits.append("flag %s=%s" % (m.group(0), m.group(1)))
272
+ for value in srv.env.values():
273
+ if str(value).strip() in PUBLIC_HOSTS:
274
+ hits.append("env value %s" % value)
275
+ for hit in hits:
276
+ findings.append(
277
+ Finding(
278
+ rule_id="BIND-ADDR",
279
+ cve_id="CVE-2025-49596",
280
+ severity="high",
281
+ server=srv.name,
282
+ evidence="server %r binds to all interfaces (%s); "
283
+ "reachable beyond localhost" % (srv.name, hit),
284
+ fix="Bind to 127.0.0.1 instead of 0.0.0.0: e.g. replace "
285
+ "--host 0.0.0.0 with --host 127.0.0.1. "
286
+ "mcp-audit fix --apply does this automatically.",
287
+ source_file=srv.source_file,
288
+ )
289
+ )
290
+ return findings
291
+
292
+ # ------------------------------------------------------ rule 4: known CVEs
293
+
294
+
295
+ def scan_known_cves(servers):
296
+ """Flag servers running package versions hit by the bundled CVE table."""
297
+ findings = []
298
+ for srv in servers:
299
+ if not srv.package:
300
+ continue
301
+ for entry in affected_cves(srv.package, srv.version):
302
+ findings.append(
303
+ Finding(
304
+ rule_id="KNOWN-CVE",
305
+ cve_id=entry["id"],
306
+ severity=entry["severity"],
307
+ server=srv.name,
308
+ evidence="server %r runs %s@%s, affected by %s (%s, CVSS %s)"
309
+ % (
310
+ srv.name,
311
+ srv.package,
312
+ srv.version,
313
+ entry["id"],
314
+ entry["title"],
315
+ entry.get("cvss") or "?",
316
+ ),
317
+ fix="Upgrade to %s or later, then restart the server. "
318
+ "Refs: %s"
319
+ % (entry.get("fixed_in") or "a patched version",
320
+ ", ".join(entry.get("references", [])) or "see NVD"),
321
+ source_file=srv.source_file,
322
+ )
323
+ )
324
+ return findings
325
+
326
+ # ------------------------------------------------------------ orchestration
327
+
328
+ SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
329
+
330
+
331
+ def run_all_scans(servers, probe=False, timeout=4.0):
332
+ findings = []
333
+ findings += scan_dangerous_stdio(servers)
334
+ findings += scan_unauthenticated_endpoints(servers, timeout=timeout, probe=probe)
335
+ findings += scan_bind_address(servers)
336
+ findings += scan_known_cves(servers)
337
+ findings.sort(key=lambda f: (SEVERITY_ORDER.get(f.severity, 5), f.server))
338
+ return findings
339
+
340
+ # ------------------------------------------------------------ one-click fix
341
+
342
+ FIXABLE_RULES = {"BIND-ADDR", "UNAUTH-ENDPOINT"}
343
+
344
+
345
+ def _apply_bind_fix(entry):
346
+ """Rewrite 0.0.0.0 / :: bindings to 127.0.0.1 in url, args, env."""
347
+ changed = False
348
+ if isinstance(entry.get("url"), str):
349
+ url = entry["url"]
350
+ new_url = re.sub(r"://(0\.0\.0\.0|::)(:|/)", r"://127.0.0.1\2", url)
351
+ if new_url != url:
352
+ entry["url"] = new_url
353
+ changed = True
354
+ args = entry.get("args")
355
+ if isinstance(args, list):
356
+ i = 0
357
+ while i < len(args):
358
+ arg = args[i]
359
+ if isinstance(arg, str):
360
+ if re.fullmatch(r"--?(?:host|bind|listen)(?:-address)?", arg):
361
+ nxt = args[i + 1] if i + 1 < len(args) else None
362
+ if nxt in ("0.0.0.0", "::"):
363
+ args[i + 1] = "127.0.0.1"
364
+ changed = True
365
+ else:
366
+ fixed = re.sub(
367
+ r"^(--?(?:host|bind|listen)(?:-address)?)=(0\.0\.0\.0|::)$",
368
+ r"\1=127.0.0.1",
369
+ arg,
370
+ )
371
+ if fixed != arg:
372
+ args[i] = fixed
373
+ changed = True
374
+ i += 1
375
+ env = entry.get("env")
376
+ if isinstance(env, dict):
377
+ for k, v in env.items():
378
+ if str(v).strip() in PUBLIC_HOSTS:
379
+ env[k] = "127.0.0.1"
380
+ changed = True
381
+ return changed
382
+
383
+
384
+ def _apply_auth_fix(entry):
385
+ if not isinstance(entry.get("headers"), dict):
386
+ entry["headers"] = {}
387
+ if not any(
388
+ k.lower() in ("authorization", "x-api-key", "x-auth-token", "cookie")
389
+ for k in entry["headers"]
390
+ ):
391
+ entry["headers"]["Authorization"] = "Bearer ${MCP_TOKEN}"
392
+ return True
393
+ return False
394
+
395
+
396
+ def plan_fixes(path, findings):
397
+ """Build a fix plan for one config file. Returns (plan, missing_servers)."""
398
+ with open(path, encoding="utf-8") as fh:
399
+ data = json.load(fh)
400
+ containers = []
401
+ for key in ("mcpServers", "servers", "mcp_servers"):
402
+ if key in data and isinstance(data[key], dict):
403
+ containers.append((key, data[key]))
404
+
405
+ plan = []
406
+ done = set()
407
+ for f in findings:
408
+ if f.rule_id not in FIXABLE_RULES:
409
+ continue
410
+ for ckey, container in containers:
411
+ if f.server in container and (f.server, f.rule_id) not in done:
412
+ plan.append(
413
+ {
414
+ "server": f.server,
415
+ "rule_id": f.rule_id,
416
+ "container": ckey,
417
+ "action": (
418
+ "bind 0.0.0.0 -> 127.0.0.1"
419
+ if f.rule_id == "BIND-ADDR"
420
+ else 'add Authorization: Bearer ${MCP_TOKEN} placeholder header'
421
+ ),
422
+ }
423
+ )
424
+ done.add((f.server, f.rule_id))
425
+ missing = [f.server for f in findings
426
+ if f.rule_id in FIXABLE_RULES and (f.server, f.rule_id) not in done]
427
+ return plan, missing
428
+
429
+
430
+ def apply_fixes(path, findings):
431
+ """Apply fixes to a config file. Returns (backup_path, applied_count)."""
432
+ with open(path, encoding="utf-8") as fh:
433
+ data = json.load(fh)
434
+
435
+ plan, _ = plan_fixes(path, findings)
436
+ if not plan:
437
+ return "", 0
438
+
439
+ backup_path = "%s.bak-%s" % (path, time.strftime("%Y%m%d-%H%M%S"))
440
+ with open(backup_path, "w", encoding="utf-8") as fh:
441
+ json.dump(data, fh, indent=2)
442
+
443
+ applied = 0
444
+ for item in plan:
445
+ container = data.get(item["container"], {})
446
+ entry = container.get(item["server"])
447
+ if not isinstance(entry, dict):
448
+ continue
449
+ if item["rule_id"] == "BIND-ADDR" and _apply_bind_fix(entry):
450
+ applied += 1
451
+ elif item["rule_id"] == "UNAUTH-ENDPOINT" and _apply_auth_fix(entry):
452
+ applied += 1
453
+
454
+ with open(path, "w", encoding="utf-8") as fh:
455
+ json.dump(data, fh, indent=2)
456
+ fh.write("\n")
457
+ return backup_path, applied
458
+
459
+ # -------------------------------------------------- agent-guard integration
460
+
461
+ AGENT_GUARD_CONFIG_PATHS = [
462
+ os.path.expanduser("~/.agent-guard/config.json"),
463
+ os.path.expanduser("~/.config/agent-guard/config.json"),
464
+ os.path.expanduser("~/.agent-guard.json"),
465
+ ]
466
+
467
+
468
+ def agent_guard_link(findings):
469
+ """Link critical findings into agent-guard's blocklist.
470
+
471
+ Returns (linked, message): linked=True when an agent-guard config was
472
+ found and updated; otherwise prints the manual linking instruction.
473
+ """
474
+ critical = sorted({f.server for f in findings if f.severity == "critical"})
475
+ if not critical:
476
+ return False, "no critical servers to link"
477
+ for path in AGENT_GUARD_CONFIG_PATHS:
478
+ if not os.path.isfile(path):
479
+ continue
480
+ try:
481
+ with open(path, encoding="utf-8") as fh:
482
+ cfg = json.load(fh)
483
+ except (OSError, json.JSONDecodeError):
484
+ continue
485
+ blocked = cfg.setdefault("blocked_servers", [])
486
+ added = 0
487
+ for name in critical:
488
+ if name not in blocked:
489
+ blocked.append(name)
490
+ added += 1
491
+ if added:
492
+ backup = "%s.bak-%s" % (path, time.strftime("%Y%m%d-%H%M%S"))
493
+ os.replace(path, backup)
494
+ with open(path, "w", encoding="utf-8") as fh:
495
+ json.dump(cfg, fh, indent=2)
496
+ return True, "linked %d critical server(s) to %s: %s" % (
497
+ added, path, ", ".join(critical))
498
+ return False, (
499
+ "agent-guard config not found. Manual link: add %s to "
500
+ "agent-guard's blocked_servers list (or run "
501
+ "'agent-guard block %s')." % (", ".join(critical), critical[0])
502
+ )
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-runtime-audit
3
+ Version: 0.1.0
4
+ Summary: Local runtime security scanner for MCP servers — known-CVE checks, one-click fixes
5
+ Author-email: hao li <haoli5933@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hahahahahahahahah6/mcp-audit
8
+ Keywords: mcp,security,scanner,cli,cve
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Topic :: Security
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # mcp-audit
18
+
19
+ A local runtime security scanner for MCP servers. Point it at your MCP client
20
+ configs and it flags the misconfigurations and known vulnerabilities that
21
+ actually get exploited — then offers one-click fixes. Zero third-party
22
+ dependencies, Python >= 3.9.
23
+
24
+ ```bash
25
+ pip install mcp-runtime-audit
26
+ mcp-audit scan
27
+ ```
28
+
29
+ ## Why this exists
30
+
31
+ MCP servers run with your user's privileges and are driven by an AI agent on
32
+ your behalf — which makes them a very different threat surface from a library
33
+ you import. The evidence so far is not reassuring:
34
+
35
+ - **The official tooling itself has shipped RCE.** CVE-2025-49596 hit
36
+ Anthropic's MCP Inspector (CVSS 9.4): its local proxy accepted an
37
+ unauthenticated `/sse` request with `transportType=stdio&command=<cmd>` and
38
+ spawned it as a subprocess — visiting a malicious page was enough to run
39
+ code on the developer's machine. Fixed in Inspector 0.14.1.
40
+ - **Enterprise MCP apps are not immune.** CVE-2026-76404 (Splunk MCP Server
41
+ < 1.2.1, CVSS 9.1): unsafe deserialization in credential management let an
42
+ admin-role user execute arbitrary OS commands (Splunk advisory
43
+ SVD-2026-0808).
44
+ - **Anthropic treats stdio risk as "expected"** — the position is that the
45
+ security burden sits with whoever deploys the server, not the protocol.
46
+ That leaves every local config file as the real security boundary, and
47
+ those files are hand-edited JSON.
48
+ - **Unpatched servers are out there in production.** Five US federal
49
+ government MCP servers were found running unpatched, and internet-wide
50
+ scans keep turning up thousands of exposed MCP endpoints, the vast majority
51
+ without any authentication.
52
+
53
+ Nobody audits their own `~/.claude.json` by hand. mcp-audit does it in a
54
+ second, in CI-friendly form.
55
+
56
+ ## How it differs
57
+
58
+ | Tool | Known-CVE version checks | One-click fixes | Focus |
59
+ |------|--------------------------|-----------------|-------|
60
+ | **mcp-audit** | ✅ bundled CVE table | ✅ `fix --apply` | runtime security |
61
+ | mcpscan | ❌ | ❌ | static config scanning |
62
+ | graygnatconsole mcp-audit-tool | ❌ | ❌ | config auditing |
63
+ | mcp-lint | n/a | n/a | **design quality** linting, not runtime security — no overlap |
64
+
65
+ mcpscan and graygnatconsole's mcp-audit-tool scan configs but neither maps
66
+ your installed server versions against known CVEs, and neither writes fixes
67
+ back. mcp-lint is a design-quality linter for MCP servers; mcp-audit is a
68
+ runtime security scanner for the servers you run — different layer, no
69
+ overlap.
70
+
71
+ ## Quick start
72
+
73
+ ```bash
74
+ pip install mcp-runtime-audit
75
+
76
+ # scan default locations (~/.claude.json, ~/.codex/config.json,
77
+ # ~/.config/mcp/servers.json, ~/.mcp.json)
78
+ mcp-audit scan
79
+
80
+ # scan a specific file, JSON output for CI
81
+ mcp-audit scan --config ~/my-claude.json --format json
82
+
83
+ # confirm unauthenticated reachability with a live probe
84
+ # (one unauthenticated GET per endpoint)
85
+ mcp-audit scan --probe
86
+
87
+ # preview fixes (dry run), then apply them (backs up first)
88
+ mcp-audit fix --config ~/.claude.json
89
+ mcp-audit fix --config ~/.claude.json --apply
90
+ ```
91
+
92
+ Exit codes: `0` clean, `1` findings, `2` errors — CI-ready.
93
+
94
+ ## What it checks
95
+
96
+ 1. **Dangerous stdio commands (`STDIO-SHELL`, high).** A stdio entry wrapping
97
+ execution in `bash -c` / `sh -c` / `cmd /c` is command injection by
98
+ design — this is the Langflow CVE-2026-105697 pattern (CVSS 9.9). Flags
99
+ the server and shows the exact invocation.
100
+ 2. **Unauthenticated SSE/HTTP endpoints (`UNAUTH-ENDPOINT`,
101
+ critical with `--probe`).** Endpoints with no `Authorization`/`X-API-Key`
102
+ header. `--probe` sends one unauthenticated GET to confirm the endpoint
103
+ actually answers — the CVE-2025-49596 shape.
104
+ 3. **Bind addresses (`BIND-ADDR`, high).** Servers bound to `0.0.0.0` or `::`
105
+ in URLs, `--host` flags, or env vars are reachable beyond localhost.
106
+ 4. **Known-CVE version checks (`KNOWN-CVE`, per-CVE severity).** Extracts
107
+ `package@version` from `npx`/`uvx` invocations and matches against the
108
+ bundled CVE table below.
109
+
110
+ Every finding carries a rule id, CVE id, severity, the evidence, and the fix
111
+ command — in both text and JSON output.
112
+
113
+ ### One-click fix
114
+
115
+ `mcp-audit fix` defaults to a dry-run plan. With `--apply` it:
116
+
117
+ - rewrites `0.0.0.0` → `127.0.0.1` in URLs, `--host` flags, and env values,
118
+ - adds an `Authorization: Bearer ${MCP_TOKEN}` placeholder header to
119
+ unauthenticated endpoints (you fill in a real token),
120
+ - backs up the config to `<file>.bak-<timestamp>` before writing,
121
+ - links critical servers into agent-guard's `blocked_servers` list when an
122
+ agent-guard config is detected (see below).
123
+
124
+ ### agent-guard integration
125
+
126
+ `mcp-audit scan --link-agent-guard` writes every critical server into
127
+ agent-guard's blocklist (config backed up first). If agent-guard isn't
128
+ installed, it prints the manual linking instruction instead.
129
+
130
+ ## CVE table
131
+
132
+ Bundled advisory snapshot (2026-10). Re-check NVD / vendor releases before
133
+ using these for compliance reporting.
134
+
135
+ | CVE | Product | Affected | Fix | Class |
136
+ |-----|---------|----------|-----|-------|
137
+ | CVE-2025-49596 | @modelcontextprotocol/inspector | < 0.14.1 | 0.14.1 | Unauthenticated RCE, CVSS 9.4 |
138
+ | CVE-2026-76404 | splunk-mcp-server | < 1.2.1 | 1.2.1 | Unsafe deserialization RCE, CVSS 9.1 |
139
+ | CVE-2026-58201 | lokka (M365 MCP) | < 2.1.2 | 2.1.2 | SSRF leaking ARM bearer token |
140
+ | CVE-2026-105697 | langflow / langflow-base / lfx | < 1.10.3 | 1.10.3 | MCP stdio `bash -c` RCE, CVSS 9.9 |
141
+ | CVE-2026-89039 | (product mapping pending) | — | — | See NVD |
142
+ | CVE-2026-105793 | (product mapping pending) | — | — | See NVD |
143
+ | CVE-2026-94486 | (product mapping pending) | — | — | See NVD |
144
+ | CVE-2026-19807 | (product mapping pending) | — | — | See NVD |
145
+
146
+ ## CI example
147
+
148
+ ```yaml
149
+ # .github/workflows/mcp-audit.yml
150
+ - uses: actions/setup-python@v5
151
+ with: { python-version: "3.12" }
152
+ - run: pip install mcp-runtime-audit
153
+ - run: mcp-audit scan --config ./mcp-servers.json --format json
154
+ # exits 1 on findings -> fails the build
155
+ ```
156
+
157
+ ## Limitations
158
+
159
+ - Version detection reads `package@version` from your config's command/args
160
+ (npx/uvx/node style). Servers installed another way, or without a pinned
161
+ version, won't match the CVE table.
162
+ - `--probe` sends one unauthenticated GET per endpoint — no auth bypass
163
+ attempts, no payloads, and it never mutates anything. It still touches
164
+ the network; don't run it against hosts you don't own.
165
+ - The CVE table is a bundled snapshot, not a live feed. New CVEs need a new
166
+ release.
167
+ - Static checks can't see what a server does after it starts (dynamic tool
168
+ behavior, prompt injection in tool outputs). This is a config/runtime
169
+ surface scanner, not a runtime behavior monitor.
170
+
171
+ ## License
172
+
173
+ MIT.
@@ -0,0 +1,10 @@
1
+ mcp_audit/__init__.py,sha256=jUN7XHOBjzPLz6d_Svx255fSoae4MdxYU5GKgOxT4yQ,114
2
+ mcp_audit/cli.py,sha256=WZN85swe6uy3QnJAYOKgmmrvathi1nsAciN1UpsXl3Q,6249
3
+ mcp_audit/cve_db.py,sha256=KBWKd6ds1dt7HdShhezZU8Opr80PZ6zu11OK4F1SIpc,7925
4
+ mcp_audit/scanners.py,sha256=l72OJYhi4hKqj1cb_ert3Pef4EGm81wpCp34hNRBaT0,18508
5
+ mcp_runtime_audit-0.1.0.dist-info/licenses/LICENSE,sha256=OYhQDg7nxWoFtnF6J8NkFUA8NMw5TKv9a1WTXHY-fu4,1063
6
+ mcp_runtime_audit-0.1.0.dist-info/METADATA,sha256=SpP_Xw8gNwaSoDnHpIcjj7s72XxD3P93UEdh7hh0M9Q,7246
7
+ mcp_runtime_audit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ mcp_runtime_audit-0.1.0.dist-info/entry_points.txt,sha256=gNxxZPRoXCWu3pELYwSNWSoyuBdKWbh153_eozNW5Jg,49
9
+ mcp_runtime_audit-0.1.0.dist-info/top_level.txt,sha256=YZ4Gy-ehEA074nfIt4cMSAS6gAJDYcWfX0gu468CZVE,10
10
+ mcp_runtime_audit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-audit = mcp_audit.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hao li
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ mcp_audit