mcp-capdiff 0.1.1__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,6 @@
1
+ """MCP Audit public package and independently evolving contract versions."""
2
+
3
+ __version__ = "0.1.1"
4
+ RULESET_VERSION = "0.1"
5
+ REPORT_SCHEMA_VERSION = "1.0"
6
+ MANIFEST_SCHEMA_VERSION = "1.0"
mcp_audit/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from mcp_audit.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Analysis entry points."""
@@ -0,0 +1,235 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from mcp_audit.analysis.scan import scan_path
8
+ from mcp_audit.models.capability import Tool
9
+ from mcp_audit.models.finding import Evidence, Finding, Location, Severity
10
+ from mcp_audit.models.report import CapabilityChange, ScanReport, SecurityDiff
11
+
12
+
13
+ def diff_against_base(base: str, target: str | Path = ".") -> SecurityDiff:
14
+ target_path = Path(target).resolve()
15
+ repo_root = _git_root(target_path.parent if target_path.is_file() else target_path)
16
+ relative_target = target_path.relative_to(repo_root)
17
+ head = scan_path(target_path)
18
+ with tempfile.TemporaryDirectory(prefix="mcp-audit-base-") as temp_dir:
19
+ base_root = Path(temp_dir) / "base"
20
+ _export_git_tree(base, base_root, repo_root)
21
+ base_target = base_root / relative_target
22
+ base_report = (
23
+ scan_path(base_target)
24
+ if base_target.exists()
25
+ else ScanReport(target=str(base_target), server_name=head.server_name)
26
+ )
27
+ if not head.tools and not base_report.tools:
28
+ raise ValueError(
29
+ f"no MCP tools discovered in either {target_path} or baseline {base}; "
30
+ "check the target path, filesystem access, and supported decorators"
31
+ )
32
+ return _build_diff(base, base_report, head)
33
+
34
+
35
+ def _git_root(cwd: Path) -> Path:
36
+ result = subprocess.run(
37
+ ["git", "rev-parse", "--show-toplevel"],
38
+ cwd=cwd,
39
+ check=True,
40
+ text=True,
41
+ stdout=subprocess.PIPE,
42
+ stderr=subprocess.PIPE,
43
+ )
44
+ return Path(result.stdout.strip()).resolve()
45
+
46
+
47
+ def _export_git_tree(base: str, destination: Path, cwd: Path) -> None:
48
+ destination.mkdir(parents=True, exist_ok=True)
49
+ archive = subprocess.run(
50
+ ["git", "archive", base],
51
+ cwd=cwd,
52
+ check=True,
53
+ stdout=subprocess.PIPE,
54
+ stderr=subprocess.PIPE,
55
+ )
56
+ subprocess.run(
57
+ ["tar", "-x", "-C", str(destination)],
58
+ input=archive.stdout,
59
+ check=True,
60
+ stdout=subprocess.PIPE,
61
+ stderr=subprocess.PIPE,
62
+ )
63
+
64
+
65
+ def _build_diff(base_name: str, base: ScanReport, head: ScanReport) -> SecurityDiff:
66
+ base_tools = {tool.name: tool for tool in base.tools}
67
+ head_tools = {tool.name: tool for tool in head.tools}
68
+ new_tools = sorted(head_tools.keys() - base_tools.keys())
69
+ removed_tools = sorted(base_tools.keys() - head_tools.keys())
70
+ changed_tools = sorted(
71
+ name
72
+ for name in head_tools.keys() & base_tools.keys()
73
+ if _tool_signature(head_tools[name]) != _tool_signature(base_tools[name])
74
+ )
75
+ capability_changes = _capability_changes(base_tools, head_tools)
76
+
77
+ base_findings = {_finding_key(finding): finding for finding in base.findings}
78
+ head_findings = {_finding_key(finding): finding for finding in head.findings}
79
+ new_findings = [head_findings[key] for key in sorted(head_findings.keys() - base_findings.keys())]
80
+ resolved_findings = [base_findings[key] for key in sorted(base_findings.keys() - head_findings.keys())]
81
+ regressions = _security_regressions(base_tools, head_tools, new_tools, capability_changes)
82
+ actionable = _deduplicate([*new_findings, *regressions])
83
+
84
+ return SecurityDiff(
85
+ target=head.target,
86
+ base=base_name,
87
+ server_name=head.server_name,
88
+ risk_before=base.risk_score,
89
+ risk_after=head.risk_score,
90
+ tools=head.tools,
91
+ findings=actionable,
92
+ new_tools=new_tools,
93
+ removed_tools=removed_tools,
94
+ changed_tools=changed_tools,
95
+ capability_changes=capability_changes,
96
+ new_findings=new_findings,
97
+ resolved_findings=resolved_findings,
98
+ )
99
+
100
+
101
+ def _capability_changes(base_tools: dict[str, Tool], head_tools: dict[str, Tool]) -> list[CapabilityChange]:
102
+ changes: list[CapabilityChange] = []
103
+ for name in sorted(base_tools.keys() & head_tools.keys()):
104
+ before = base_tools[name].capability.as_dict()
105
+ after = head_tools[name].capability.as_dict()
106
+ for capability in sorted(before.keys() | after.keys()):
107
+ if before.get(capability) != after.get(capability):
108
+ changes.append(CapabilityChange(name, capability, before.get(capability), after.get(capability)))
109
+ return changes
110
+
111
+
112
+ def _security_regressions(
113
+ base_tools: dict[str, Tool],
114
+ head_tools: dict[str, Tool],
115
+ new_tools: list[str],
116
+ changes: list[CapabilityChange],
117
+ ) -> list[Finding]:
118
+ findings: list[Finding] = []
119
+ for name in new_tools:
120
+ tool = head_tools[name]
121
+ if _is_high_impact(tool):
122
+ findings.append(
123
+ Finding(
124
+ rule_id="MCP016",
125
+ title="Capability escalation",
126
+ severity=Severity.HIGH,
127
+ tool=name,
128
+ message="A new tool introduces a high-impact security capability.",
129
+ impact="The MCP server's authority expands beyond the reviewed baseline.",
130
+ recommendation="Review the capability, require approval where appropriate, and update the approved manifest.",
131
+ location=_location(tool.source),
132
+ evidence=[Evidence(message=f"New capabilities: {tool.capability.as_dict()}", kind="capability_diff")],
133
+ )
134
+ )
135
+ changed_names = {change.tool for change in changes}
136
+ for name in sorted(changed_names):
137
+ previous = base_tools[name]
138
+ tool = head_tools[name]
139
+ if previous.capability.requires_approval and not tool.capability.requires_approval:
140
+ findings.append(
141
+ Finding(
142
+ rule_id="MCP017",
143
+ title="Approval removed",
144
+ severity=Severity.HIGH,
145
+ tool=name,
146
+ message="A previously approval-gated tool no longer requires approval.",
147
+ impact="High-impact actions may now execute without human confirmation.",
148
+ recommendation="Restore the approval boundary or document the reviewed policy exception.",
149
+ location=_location(tool.source),
150
+ evidence=[Evidence(message="requires_approval: true -> false", kind="capability_diff")],
151
+ )
152
+ )
153
+ expanded = [change for change in changes if change.tool == name and _is_expansion(change)]
154
+ if expanded:
155
+ findings.append(
156
+ Finding(
157
+ rule_id="MCP016",
158
+ title="Capability escalation",
159
+ severity=Severity.HIGH,
160
+ tool=name,
161
+ message="A tool capability expanded relative to the Git baseline.",
162
+ impact="Existing clients may gain access to broader resources or side effects.",
163
+ recommendation="Constrain the capability or approve and document the expanded baseline.",
164
+ location=_location(tool.source),
165
+ evidence=[
166
+ Evidence(message=f"{change.capability}: {change.before} -> {change.after}", kind="capability_diff")
167
+ for change in expanded
168
+ ],
169
+ )
170
+ )
171
+ return findings
172
+
173
+
174
+ def _tool_signature(tool: Tool) -> tuple[object, ...]:
175
+ return (
176
+ tuple(sorted(tool.capability.as_dict().items(), key=lambda item: item[0])),
177
+ tuple((parameter.name, parameter.annotation, parameter.bounded) for parameter in tool.parameters),
178
+ )
179
+
180
+
181
+ def _finding_key(finding: Finding) -> tuple[str, str]:
182
+ return finding.rule_id, finding.tool or ""
183
+
184
+
185
+ def _deduplicate(findings: list[Finding]) -> list[Finding]:
186
+ result: dict[tuple[str, str], Finding] = {}
187
+ for finding in findings:
188
+ result.setdefault(_finding_key(finding), finding)
189
+ return list(result.values())
190
+
191
+
192
+ def _is_high_impact(tool: Tool) -> bool:
193
+ capability = tool.capability
194
+ return (
195
+ capability.network == "unrestricted_outbound"
196
+ or capability.filesystem == "unrestricted"
197
+ or capability.execution != "none"
198
+ or capability.side_effect in {"external_write", "destructive_action"}
199
+ )
200
+
201
+
202
+ def _is_expansion(change: CapabilityChange) -> bool:
203
+ if change.capability in {"filesystem", "network", "execution", "side_effect"}:
204
+ return _rank(str(change.after)) > _rank(str(change.before))
205
+ if change.capability in {"destructive", "financial"}:
206
+ return change.before is False and change.after is True
207
+ if change.capability == "data_access":
208
+ return bool(set(change.after or []) - set(change.before or []))
209
+ return False
210
+
211
+
212
+ def _rank(value: str) -> int:
213
+ return {
214
+ "none": 0,
215
+ "fixed_file": 1,
216
+ "allowlisted_files": 2,
217
+ "allowlisted_hosts": 2,
218
+ "local_write": 2,
219
+ "directory": 3,
220
+ "external_write": 4,
221
+ "multiple_directories": 4,
222
+ "destructive_action": 5,
223
+ "unrestricted": 5,
224
+ "unrestricted_outbound": 5,
225
+ "shell": 5,
226
+ "arbitrary_code": 6,
227
+ }.get(value, 0)
228
+
229
+
230
+ def _location(source: str) -> Location:
231
+ path, _, line = source.rpartition(":")
232
+ try:
233
+ return Location(path=path or source, line=int(line or "1"))
234
+ except ValueError:
235
+ return Location(path=source)
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from mcp_audit.models.report import ScanReport
6
+ from mcp_audit.rules.engine import evaluate_tools
7
+ from mcp_audit.scanners.source.python import scan_python_sources
8
+
9
+
10
+ def scan_path(path: str | Path, *, require_tools: bool = False) -> ScanReport:
11
+ target = Path(path).resolve()
12
+ tools = scan_python_sources(target)
13
+ if require_tools and not tools:
14
+ raise ValueError(
15
+ f"no MCP tools discovered in {target}; check the target path, filesystem access, and supported decorators"
16
+ )
17
+ findings = evaluate_tools(tools)
18
+ return ScanReport(
19
+ target=str(target),
20
+ server_name=_server_name(target),
21
+ tools=tools,
22
+ findings=findings,
23
+ )
24
+
25
+
26
+ def _server_name(target: Path) -> str:
27
+ if target.is_file():
28
+ return target.stem
29
+ return target.name
@@ -0,0 +1 @@
1
+ """CLI package."""
mcp_audit/cli/main.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from mcp_audit import __version__
8
+ from mcp_audit.analysis.diff import diff_against_base
9
+ from mcp_audit.analysis.scan import scan_path
10
+ from mcp_audit.models.finding import Severity
11
+ from mcp_audit.policy.evaluator import apply_policy
12
+ from mcp_audit.policy.loader import load_policy
13
+ from mcp_audit.reporters.json import render_json
14
+ from mcp_audit.reporters.manifest import render_manifest
15
+ from mcp_audit.reporters.sarif import render_sarif
16
+ from mcp_audit.reporters.terminal import render_diff_terminal, render_terminal
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ parser = _parser()
21
+ args = parser.parse_args(argv)
22
+ try:
23
+ if args.command == "scan":
24
+ report = scan_path(args.target, require_tools=not args.allow_empty)
25
+ policy = load_policy(args.policy)
26
+ fail_on = Severity.parse(args.fail_on) if args.fail_on else None
27
+ report = apply_policy(report, policy, fail_on)
28
+ _write(_render(report, args.format), args.output)
29
+ return 1 if report.result == "fail" else 0
30
+ if args.command == "manifest":
31
+ report = scan_path(args.target, require_tools=True)
32
+ _write(render_manifest(report), args.output)
33
+ return 0
34
+ if args.command == "diff":
35
+ report = diff_against_base(args.base, args.target)
36
+ policy = load_policy(args.policy)
37
+ fail_on = Severity.parse(args.fail_on) if args.fail_on else None
38
+ report = apply_policy(report, policy, fail_on)
39
+ _write(_render(report, args.format), args.output)
40
+ return 1 if report.result == "fail" else 0
41
+ if args.command == "policy" and args.policy_command == "check":
42
+ policy = load_policy(args.policy)
43
+ expired = policy.expired_suppressions()
44
+ if expired:
45
+ for suppression in expired:
46
+ print(
47
+ f"Expired suppression: {suppression.rule_id} "
48
+ f"({suppression.tool or 'all tools'}) expired {suppression.expires}",
49
+ file=sys.stderr,
50
+ )
51
+ return 1
52
+ print(f"Policy OK: {args.policy}")
53
+ return 0
54
+ except Exception as exc:
55
+ print(f"mcp-audit: {exc}", file=sys.stderr)
56
+ return 2
57
+ parser.print_help()
58
+ return 2
59
+
60
+
61
+ def _parser() -> argparse.ArgumentParser:
62
+ parser = argparse.ArgumentParser(prog="mcp-audit", description="Security regression scanner for MCP servers.")
63
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
64
+ subcommands = parser.add_subparsers(dest="command")
65
+
66
+ scan = subcommands.add_parser("scan", help="scan local source")
67
+ scan.add_argument("target", nargs="?", default=".")
68
+ scan.add_argument(
69
+ "--allow-empty",
70
+ action="store_true",
71
+ help="allow a successful scan when no MCP tools are discovered",
72
+ )
73
+ _report_options(scan)
74
+
75
+ manifest = subcommands.add_parser("manifest", help="generate a capability manifest")
76
+ manifest.add_argument("target", nargs="?", default=".")
77
+ manifest.add_argument("--output")
78
+
79
+ diff = subcommands.add_parser("diff", help="compare the current tree with a git baseline")
80
+ diff.add_argument("target", nargs="?", default=".")
81
+ diff.add_argument("--base", required=True)
82
+ _report_options(diff)
83
+
84
+ policy = subcommands.add_parser("policy", help="policy utilities")
85
+ policy_subcommands = policy.add_subparsers(dest="policy_command")
86
+ check = policy_subcommands.add_parser("check", help="validate that a policy file can be loaded")
87
+ check.add_argument("policy")
88
+ return parser
89
+
90
+
91
+ def _report_options(parser: argparse.ArgumentParser) -> None:
92
+ parser.add_argument("--format", choices=["terminal", "json", "sarif"], default="terminal")
93
+ parser.add_argument("--output")
94
+ parser.add_argument("--policy")
95
+ parser.add_argument("--fail-on", choices=["low", "medium", "high", "critical"])
96
+
97
+
98
+ def _render(report, fmt: str) -> str:
99
+ if fmt == "json":
100
+ return render_json(report)
101
+ if fmt == "sarif":
102
+ return render_sarif(report)
103
+ if hasattr(report, "base"):
104
+ return render_diff_terminal(report)
105
+ return render_terminal(report)
106
+
107
+
108
+ def _write(text: str, output: str | None) -> None:
109
+ if output:
110
+ Path(output).write_text(text, encoding="utf-8")
111
+ else:
112
+ print(text)
@@ -0,0 +1,12 @@
1
+ from mcp_audit.models.capability import Capability, Tool
2
+ from mcp_audit.models.finding import Finding, Location, Severity
3
+ from mcp_audit.models.report import ScanReport
4
+
5
+ __all__ = [
6
+ "Capability",
7
+ "Finding",
8
+ "Location",
9
+ "ScanReport",
10
+ "Severity",
11
+ "Tool",
12
+ ]
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from mcp_audit.models.finding import Evidence
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class Capability:
10
+ data_access: set[str] = field(default_factory=set)
11
+ side_effect: str = "none"
12
+ filesystem: str = "none"
13
+ network: str = "none"
14
+ network_destination: str | None = None
15
+ execution: str = "none"
16
+ sensitivity: str = "public"
17
+ destructive: bool = False
18
+ financial: bool = False
19
+ requires_approval: bool = False
20
+ audit_metadata: bool = False
21
+
22
+ def as_dict(self) -> dict[str, object]:
23
+ return {
24
+ "data_access": sorted(self.data_access),
25
+ "side_effect": self.side_effect,
26
+ "filesystem": self.filesystem,
27
+ "network": self.network,
28
+ "network_destination": self.network_destination,
29
+ "execution": self.execution,
30
+ "sensitivity": self.sensitivity,
31
+ "destructive": self.destructive,
32
+ "financial": self.financial,
33
+ "requires_approval": self.requires_approval,
34
+ "audit_metadata": self.audit_metadata,
35
+ }
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class Parameter:
40
+ name: str
41
+ annotation: str | None = None
42
+ default: str | None = None
43
+ bounded: bool = False
44
+
45
+ def as_dict(self) -> dict[str, object]:
46
+ return {
47
+ "name": self.name,
48
+ "annotation": self.annotation,
49
+ "default": self.default,
50
+ "bounded": self.bounded,
51
+ }
52
+
53
+
54
+ @dataclass(slots=True)
55
+ class Tool:
56
+ name: str
57
+ source: str
58
+ context: str = ""
59
+ description: str = ""
60
+ parameters: list[Parameter] = field(default_factory=list)
61
+ capability: Capability = field(default_factory=Capability)
62
+ evidence: list[Evidence] = field(default_factory=list)
63
+
64
+ def as_dict(self) -> dict[str, object]:
65
+ return {
66
+ "name": self.name,
67
+ "source": self.source,
68
+ "context": self.context,
69
+ "description": self.description,
70
+ "parameters": [parameter.as_dict() for parameter in self.parameters],
71
+ "capabilities": self.capability.as_dict(),
72
+ "evidence": [item.as_dict() for item in self.evidence],
73
+ }
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import IntEnum
5
+
6
+
7
+ class Severity(IntEnum):
8
+ LOW = 1
9
+ MEDIUM = 2
10
+ HIGH = 3
11
+ CRITICAL = 4
12
+
13
+ @classmethod
14
+ def parse(cls, value: str) -> "Severity":
15
+ normalized = value.strip().upper()
16
+ try:
17
+ return cls[normalized]
18
+ except KeyError as exc:
19
+ valid = ", ".join(severity.name.lower() for severity in cls)
20
+ raise ValueError(f"unknown severity '{value}', expected one of: {valid}") from exc
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class Location:
25
+ path: str
26
+ line: int = 1
27
+ column: int | None = None
28
+ end_line: int | None = None
29
+ end_column: int | None = None
30
+
31
+ def as_dict(self) -> dict[str, object]:
32
+ result: dict[str, object] = {"path": self.path, "line": self.line}
33
+ if self.column is not None:
34
+ result["column"] = self.column
35
+ if self.end_line is not None:
36
+ result["end_line"] = self.end_line
37
+ if self.end_column is not None:
38
+ result["end_column"] = self.end_column
39
+ return result
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class Evidence:
44
+ message: str
45
+ location: Location | None = None
46
+ snippet: str | None = None
47
+ kind: str = "code"
48
+
49
+ def as_dict(self) -> dict[str, object]:
50
+ return {
51
+ "kind": self.kind,
52
+ "message": self.message,
53
+ "location": self.location.as_dict() if self.location else None,
54
+ "snippet": self.snippet,
55
+ }
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class Finding:
60
+ rule_id: str
61
+ title: str
62
+ severity: Severity
63
+ tool: str | None
64
+ message: str
65
+ recommendation: str
66
+ location: Location
67
+ impact: str = ""
68
+ evidence: list[Evidence] = field(default_factory=list)
69
+ path: list[str] = field(default_factory=list)
70
+ data_classification: list[str] = field(default_factory=list)
71
+ destination: str | None = None
72
+
73
+ def as_dict(self) -> dict[str, object]:
74
+ return {
75
+ "rule_id": self.rule_id,
76
+ "title": self.title,
77
+ "severity": self.severity.name.lower(),
78
+ "tool": self.tool,
79
+ "message": self.message,
80
+ "impact": self.impact,
81
+ "recommendation": self.recommendation,
82
+ "location": self.location.as_dict(),
83
+ "evidence": [item.as_dict() for item in self.evidence],
84
+ "path": self.path,
85
+ "data_classification": self.data_classification,
86
+ "destination": self.destination,
87
+ }