mcplens-scanner 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.
mcplens/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """mcplens — MCP server security scanner with runtime behavioral verification."""
2
+
3
+ __version__ = "0.1.0"
mcplens/baseline.py ADDED
@@ -0,0 +1,102 @@
1
+ """Baseline snapshots and rug-pull diffing.
2
+
3
+ A baseline pins the SHA-256 of every tool's canonical definition
4
+ (name + description + inputSchema). Re-scanning and diffing against the
5
+ baseline exposes silent definition changes — the 'rug pull' pattern where
6
+ a server behaves during review and mutates afterwards.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from typing import Any, Optional
15
+
16
+ from .models import ToolInfo
17
+
18
+ SNAPSHOT_VERSION = 1
19
+
20
+
21
+ def tool_hash(tool: ToolInfo) -> str:
22
+ return hashlib.sha256(tool.canonical_json().encode("utf-8")).hexdigest()
23
+
24
+
25
+ def set_hash(tools: list[ToolInfo]) -> str:
26
+ joined = "\n".join(f"{t.name}:{tool_hash(t)}" for t in sorted(tools, key=lambda x: x.name))
27
+ return hashlib.sha256(joined.encode("utf-8")).hexdigest()
28
+
29
+
30
+ def make_snapshot(tools: list[ToolInfo], label: str = "") -> dict[str, Any]:
31
+ return {
32
+ "mcplens_snapshot": SNAPSHOT_VERSION,
33
+ "created": datetime.now(timezone.utc).isoformat(),
34
+ "target": label,
35
+ "set_sha256": set_hash(tools),
36
+ "tools": {
37
+ t.name: {"sha256": tool_hash(t), "definition": t.canonical()}
38
+ for t in tools
39
+ },
40
+ }
41
+
42
+
43
+ def save_snapshot(snapshot: dict[str, Any], path: str) -> None:
44
+ with open(path, "w", encoding="utf-8") as fh:
45
+ json.dump(snapshot, fh, indent=2, sort_keys=True)
46
+ fh.write("\n")
47
+
48
+
49
+ def load_snapshot(path: str) -> dict[str, Any]:
50
+ with open(path, "r", encoding="utf-8") as fh:
51
+ data = json.load(fh)
52
+ if data.get("mcplens_snapshot") != SNAPSHOT_VERSION:
53
+ raise ValueError(f"{path} is not an mcplens snapshot (version mismatch)")
54
+ return data
55
+
56
+
57
+ @dataclass
58
+ class ToolChange:
59
+ name: str
60
+ kind: str # "added" | "removed" | "changed"
61
+ fields: list[str] = field(default_factory=list) # for "changed": which fields differ
62
+
63
+
64
+ @dataclass
65
+ class DiffResult:
66
+ changes: list[ToolChange] = field(default_factory=list)
67
+ old_set_hash: str = ""
68
+ new_set_hash: str = ""
69
+
70
+ @property
71
+ def has_changes(self) -> bool:
72
+ return bool(self.changes)
73
+
74
+
75
+ def _field_diffs(old_def: dict[str, Any], new_def: dict[str, Any]) -> list[str]:
76
+ fields = []
77
+ if old_def.get("description") != new_def.get("description"):
78
+ fields.append("description")
79
+ if json.dumps(old_def.get("inputSchema") or {}, sort_keys=True) != \
80
+ json.dumps(new_def.get("inputSchema") or {}, sort_keys=True):
81
+ fields.append("inputSchema")
82
+ return fields
83
+
84
+
85
+ def diff_snapshot(snapshot: dict[str, Any], current: list[ToolInfo]) -> DiffResult:
86
+ old_tools: dict[str, Any] = snapshot.get("tools") or {}
87
+ result = DiffResult(old_set_hash=snapshot.get("set_sha256", ""),
88
+ new_set_hash=set_hash(current))
89
+ current_by_name = {t.name: t for t in current}
90
+
91
+ for name in sorted(set(old_tools) - set(current_by_name)):
92
+ result.changes.append(ToolChange(name=name, kind="removed"))
93
+ for name in sorted(set(current_by_name) - set(old_tools)):
94
+ result.changes.append(ToolChange(name=name, kind="added"))
95
+ for name in sorted(set(old_tools) & set(current_by_name)):
96
+ tool = current_by_name[name]
97
+ if old_tools[name].get("sha256") != tool_hash(tool):
98
+ old_def = old_tools[name].get("definition") or {}
99
+ fields = _field_diffs(old_def, tool.canonical())
100
+ result.changes.append(ToolChange(name=name, kind="changed",
101
+ fields=fields or ["definition"]))
102
+ return result
mcplens/cli.py ADDED
@@ -0,0 +1,157 @@
1
+ """mcplens command-line interface.
2
+
3
+ Commands:
4
+ mcplens scan --cmd "..." | --config cfg.json [--name X] | --url URL
5
+ [--probes] [--format text|json]
6
+ mcplens baseline (same target flags) [--out mcplens-baseline.json]
7
+ mcplens diff (same target flags) [--baseline mcplens-baseline.json]
8
+
9
+ Exit codes: scan -> 1 if any critical finding, else 0.
10
+ diff -> 1 if tool definitions drifted from the baseline.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+
18
+ from . import __version__
19
+ from .baseline import diff_snapshot, load_snapshot, make_snapshot, save_snapshot
20
+ from .models import ScanReport
21
+ from .report import render_diff, render_text
22
+ from .rules import analyze_tools
23
+ from .transport import Target, fetch_tools
24
+
25
+
26
+ def _add_target_args(p: argparse.ArgumentParser) -> None:
27
+ g = p.add_argument_group("target (pick one)")
28
+ g.add_argument("--cmd", metavar="COMMAND",
29
+ help='stdio server command, e.g. --cmd "python server.py"')
30
+ g.add_argument("--config", metavar="PATH",
31
+ help="client config JSON (mcpServers format)")
32
+ g.add_argument("--name", metavar="SERVER",
33
+ help="server name inside --config (required if it holds several)")
34
+ g.add_argument("--url", metavar="URL",
35
+ help="streamable HTTP MCP endpoint URL")
36
+
37
+
38
+ def _resolve_target(args: argparse.Namespace) -> Target:
39
+ if getattr(args, "url", None):
40
+ return Target.from_url(args.url)
41
+ if getattr(args, "config", None):
42
+ return Target.from_config(args.config, args.name)
43
+ if getattr(args, "cmd", None):
44
+ return Target.from_command(args.cmd)
45
+ raise SystemExit("error: specify a target with --cmd, --config, or --url")
46
+
47
+
48
+ def build_parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(
50
+ prog="mcplens",
51
+ description="MCP server security scanner with runtime behavioral verification.",
52
+ )
53
+ parser.add_argument("--version", action="version", version=f"mcplens {__version__}")
54
+ sub = parser.add_subparsers(dest="command", required=True)
55
+
56
+ p_scan = sub.add_parser("scan", help="enumerate tools, run static rules, optionally probe behavior")
57
+ _add_target_args(p_scan)
58
+ p_scan.add_argument("--probes", action="store_true",
59
+ help="run dynamic canary probes in a sandbox (stdio targets only)")
60
+ p_scan.add_argument("--format", choices=("text", "json"), default="text")
61
+
62
+ p_base = sub.add_parser("baseline", help="save a hash snapshot of tool definitions")
63
+ _add_target_args(p_base)
64
+ p_base.add_argument("--out", default="mcplens-baseline.json",
65
+ help="snapshot file to write (default: mcplens-baseline.json)")
66
+
67
+ p_diff = sub.add_parser("diff", help="compare current tool definitions against a baseline")
68
+ _add_target_args(p_diff)
69
+ p_diff.add_argument("--baseline", default="mcplens-baseline.json",
70
+ help="snapshot file to compare against (default: mcplens-baseline.json)")
71
+ return parser
72
+
73
+
74
+ def cmd_scan(args: argparse.Namespace) -> int:
75
+ target = _resolve_target(args)
76
+ report = ScanReport(target_label=target.label, transport=target.kind)
77
+
78
+ probe_findings = []
79
+ if args.probes:
80
+ if target.kind != "stdio":
81
+ print("error: --probes requires a stdio target (--cmd or --config)", file=sys.stderr)
82
+ return 2
83
+ from .probes import run_probes
84
+ run = run_probes(target)
85
+ report.tools = run.tools
86
+ report.probes = run.results
87
+ report.probes_run = True
88
+ probe_findings = run.findings
89
+ else:
90
+ report.tools = fetch_tools(target)
91
+
92
+ static_findings = analyze_tools(report.tools,
93
+ target_url=target.url if target.kind == "http" else "")
94
+ report.findings = static_findings + probe_findings
95
+
96
+ if args.format == "json":
97
+ print(json.dumps(report.to_dict(), indent=2))
98
+ else:
99
+ print(render_text(report))
100
+ return 1 if any(f.severity == "critical" for f in report.findings) else 0
101
+
102
+
103
+ def cmd_baseline(args: argparse.Namespace) -> int:
104
+ target = _resolve_target(args)
105
+ tools = fetch_tools(target)
106
+ snapshot = make_snapshot(tools, label=target.label)
107
+ save_snapshot(snapshot, args.out)
108
+ print(f"Baseline saved to {args.out}")
109
+ print(f" target: {target.label}")
110
+ print(f" tools: {len(tools)}")
111
+ print(f" set hash: {snapshot['set_sha256']}")
112
+ for t in tools:
113
+ print(f" {t.name:<32} {snapshot['tools'][t.name]['sha256'][:16]}…")
114
+ return 0
115
+
116
+
117
+ def cmd_diff(args: argparse.Namespace) -> int:
118
+ target = _resolve_target(args)
119
+ try:
120
+ snapshot = load_snapshot(args.baseline)
121
+ except (OSError, ValueError) as exc:
122
+ print(f"error: cannot load baseline: {exc}", file=sys.stderr)
123
+ return 2
124
+ tools = fetch_tools(target)
125
+ result = diff_snapshot(snapshot, tools)
126
+ print(render_diff(result, target.label))
127
+ return 1 if result.has_changes else 0
128
+
129
+
130
+ def _flatten(exc: BaseException) -> str:
131
+ """One-line message for an exception, unwrapping exception groups."""
132
+ if isinstance(exc, BaseExceptionGroup) and exc.exceptions:
133
+ return _flatten(exc.exceptions[0])
134
+ return str(exc) or type(exc).__name__
135
+
136
+
137
+ def main(argv: list[str] | None = None) -> int:
138
+ parser = build_parser()
139
+ args = parser.parse_args(argv)
140
+ try:
141
+ if args.command == "scan":
142
+ return cmd_scan(args)
143
+ if args.command == "baseline":
144
+ return cmd_baseline(args)
145
+ if args.command == "diff":
146
+ return cmd_diff(args)
147
+ except (ValueError, OSError) as exc:
148
+ print(f"error: {exc}", file=sys.stderr)
149
+ return 2
150
+ except BaseExceptionGroup as group: # transport/task-group failures
151
+ print(f"error: {_flatten(group)}", file=sys.stderr)
152
+ return 2
153
+ return 2
154
+
155
+
156
+ if __name__ == "__main__":
157
+ raise SystemExit(main())
mcplens/models.py ADDED
@@ -0,0 +1,116 @@
1
+ """Core data structures for mcplens."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass, field, asdict
6
+ from typing import Any, Optional
7
+
8
+ SEVERITIES = ("critical", "high", "medium", "low", "info")
9
+ SEVERITY_ORDER = {name: i for i, name in enumerate(SEVERITIES)}
10
+ SEVERITY_PENALTY = {"critical": 40, "high": 20, "medium": 8, "low": 3, "info": 1}
11
+
12
+
13
+ @dataclass
14
+ class ToolInfo:
15
+ """A single tool definition as advertised by an MCP server (tools/list)."""
16
+
17
+ name: str
18
+ description: str = ""
19
+ input_schema: dict[str, Any] = field(default_factory=dict)
20
+
21
+ @classmethod
22
+ def from_mcp(cls, tool: Any) -> "ToolInfo":
23
+ schema = getattr(tool, "inputSchema", None) or \
24
+ getattr(tool, "input_schema", None) or {}
25
+ if not isinstance(schema, dict):
26
+ try:
27
+ schema = dict(schema)
28
+ except Exception:
29
+ schema = {}
30
+ return cls(
31
+ name=getattr(tool, "name", "") or "",
32
+ description=getattr(tool, "description", None) or "",
33
+ input_schema=schema,
34
+ )
35
+
36
+ def canonical(self) -> dict[str, Any]:
37
+ return {
38
+ "name": self.name,
39
+ "description": self.description,
40
+ "inputSchema": self.input_schema,
41
+ }
42
+
43
+ def canonical_json(self) -> str:
44
+ return json.dumps(self.canonical(), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
45
+
46
+
47
+ @dataclass
48
+ class Finding:
49
+ rule_id: str
50
+ severity: str # one of SEVERITIES
51
+ title: str
52
+ tool: Optional[str] # tool name, or None for server-level findings
53
+ detail: str
54
+ owasp: str = "" # OWASP MCP Top 10 mapping, e.g. "MCP05 Tool Poisoning"
55
+ source: str = "static" # "static" or "probe"
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ return asdict(self)
59
+
60
+
61
+ @dataclass
62
+ class ToolProbeResult:
63
+ tool: str
64
+ probed: bool
65
+ note: str = ""
66
+ declared: list[str] = field(default_factory=list) # behaviors implied by name/description
67
+ observed: list[str] = field(default_factory=list) # behaviors actually observed
68
+ error: Optional[str] = None
69
+
70
+ def to_dict(self) -> dict[str, Any]:
71
+ return asdict(self)
72
+
73
+
74
+ @dataclass
75
+ class ScanReport:
76
+ target_label: str
77
+ transport: str # "stdio" or "http"
78
+ tools: list[ToolInfo] = field(default_factory=list)
79
+ findings: list[Finding] = field(default_factory=list)
80
+ probes: list[ToolProbeResult] = field(default_factory=list)
81
+ probes_run: bool = False
82
+
83
+ @property
84
+ def score(self) -> int:
85
+ score = 100
86
+ for f in self.findings:
87
+ score -= SEVERITY_PENALTY.get(f.severity, 0)
88
+ return max(0, score)
89
+
90
+ @property
91
+ def grade(self) -> str:
92
+ s = self.score
93
+ if s >= 90:
94
+ return "A"
95
+ if s >= 75:
96
+ return "B"
97
+ if s >= 60:
98
+ return "C"
99
+ if s >= 40:
100
+ return "D"
101
+ return "F"
102
+
103
+ def sorted_findings(self) -> list[Finding]:
104
+ return sorted(self.findings, key=lambda f: (SEVERITY_ORDER.get(f.severity, 99), f.rule_id))
105
+
106
+ def to_dict(self) -> dict[str, Any]:
107
+ return {
108
+ "target": self.target_label,
109
+ "transport": self.transport,
110
+ "grade": self.grade,
111
+ "score": self.score,
112
+ "tools": [t.canonical() for t in self.tools],
113
+ "findings": [f.to_dict() for f in self.sorted_findings()],
114
+ "probes_run": self.probes_run,
115
+ "probes": [p.to_dict() for p in self.probes],
116
+ }