mcplens-scanner 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Karmendra Pandey
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,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcplens-scanner
3
+ Version: 0.1.0
4
+ Summary: MCP server security scanner with runtime behavioral verification — checks that tools actually do what their descriptions claim.
5
+ Author: Karmendra Pandey
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/karmendra8386/mcplens
8
+ Project-URL: Repository, https://github.com/karmendra8386/mcplens
9
+ Keywords: mcp,model-context-protocol,security,scanner,ai-agents,tool-poisoning
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Security
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: mcp>=2.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # mcplens
24
+
25
+ **An MCP server security scanner that doesn't just read what a server claims — it tests what the server actually does.**
26
+
27
+ Static scanners for the Model Context Protocol (MCP) read tool descriptions and configs. That's table stakes, and mcplens does it too. But a tool description is a *claim*, and the worst MCP attacks — tool poisoning, rug pulls, confused-deputy tools — are lies told in exactly those claims.
28
+
29
+ mcplens adds **runtime behavioral verification**:
30
+
31
+ - It launches the server in a sandbox whose `HOME` is stocked with **canary credential files** (a fake `~/.ssh/id_rsa`, fake AWS credentials — each with a unique token).
32
+ - It invokes every tool once with safe, schema-derived benign inputs.
33
+ - It watches what actually happens: canary tokens leaking into responses, writes to the filesystem from "read-only" tools, network requests the description never mentions.
34
+ - Every probed tool gets a **declared-vs-observed report card**.
35
+
36
+ And because servers change after you approve them, mcplens **baselines** tool definitions with SHA-256 hashes and **diffs** them later — the rug-pull watch.
37
+
38
+ Maintained by **Karmendra Pandey**. MIT licensed.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install mcplens-scanner # (PyPI release pending; for now install from source)
44
+ git clone https://github.com/karmendra8386/mcplens && cd mcplens
45
+ pip install -e .
46
+ ```
47
+
48
+ Requires Python 3.10+ and the official `mcp` Python SDK (installed automatically).
49
+
50
+ ## Usage
51
+
52
+ ### Scan a server (static rules)
53
+
54
+ ```bash
55
+ # stdio server by command
56
+ mcplens scan --cmd "python my_server.py"
57
+
58
+ # from a client config (Claude Desktop / mcpServers format)
59
+ mcplens scan --config ~/.config/claude/claude_desktop_config.json --name my-server
60
+
61
+ # remote streamable-HTTP server
62
+ mcplens scan --url https://example.com/mcp
63
+ ```
64
+
65
+ ### Scan with behavioral probes (stdio servers)
66
+
67
+ ```bash
68
+ mcplens scan --probes --cmd "python my_server.py"
69
+ ```
70
+
71
+ Probes run the server with `HOME` and the working directory pointed at a throwaway sandbox. Your real files are never touched, probes never pass destructive payloads, tools with free-form command parameters are never exercised, and destructive-looking tools are skipped and reported as *not probed*.
72
+
73
+ Machine-readable output:
74
+
75
+ ```bash
76
+ mcplens scan --probes --format json --cmd "python my_server.py"
77
+ ```
78
+
79
+ Exit code is `1` when any critical finding is present, `0` otherwise — CI-friendly.
80
+
81
+ ### Baseline & rug-pull diff
82
+
83
+ ```bash
84
+ mcplens baseline --cmd "python my_server.py" # writes mcplens-baseline.json
85
+ # ...later, after the server updates...
86
+ mcplens diff --cmd "python my_server.py" # exit 1 if definitions drifted
87
+ ```
88
+
89
+ The baseline stores the SHA-256 of every tool's canonical definition (name + description + input schema) plus a whole-set hash. `diff` reports added, removed, and changed tools, with the changed fields named.
90
+
91
+ ## What it catches
92
+
93
+ Sample output against the deliberately vulnerable fixture in `fixtures/`:
94
+
95
+ ```
96
+ Findings: 5 (critical 4, high 1, medium 0, low 0, info 0)
97
+ [CRITICAL] MCPL001 (static) add: Instruction injection in tool description
98
+ [CRITICAL] MCPL003 (static) add: Tool description references sensitive files
99
+ [CRITICAL] MCPL101 (probe) get_system_info: Tool returned canary credential contents
100
+ [CRITICAL] MCPL103 (probe) get_system_info: Read-only-claimed tool modified the filesystem
101
+ [HIGH ] MCPL005 (static) run_command: Unconstrained shell/command parameter
102
+
103
+ Behavioral probes (declared vs observed):
104
+ - get_system_info: declared [read-only access] | observed [returned a response;
105
+ returned contents of canary file(s): .ssh/id_rsa;
106
+ filesystem changes: home/.mcplens_was_here]
107
+
108
+ Grade: F (score 0/100)
109
+ ```
110
+
111
+ Note what happened there: `get_system_info` looks *clean* to static analysis — its description is a lie, and only running it exposes the lie. That gap is why mcplens exists.
112
+
113
+ ## Rule catalog
114
+
115
+ Static rules (see [docs/rule-catalog.md](docs/rule-catalog.md)):
116
+
117
+ | ID | Severity | Check | OWASP MCP Top 10 |
118
+ |---|---|---|---|
119
+ | MCPL001 | critical | Instruction injection in tool description | MCP05 Tool Poisoning |
120
+ | MCPL002 | high | Hidden / bidirectional Unicode in tool definition | MCP05 Tool Poisoning |
121
+ | MCPL003 | critical | Description references sensitive files (`~/.ssh`, `.env`, …) | MCP05 Tool Poisoning |
122
+ | MCPL004 | medium | Look-alike tool names (possible shadowing) | MCP05 Tool Poisoning |
123
+ | MCPL005 | high | Unconstrained shell/command parameter | MCP04 Command Injection |
124
+ | MCPL006 | medium | Parameter with no type constraint | MCP09 Protocol & Transport |
125
+ | MCPL007 | medium | Schema allows arbitrary additional properties | MCP06 Privilege Escalation |
126
+ | MCPL008 | medium | Read-only-sounding tool describes write/exec behavior | MCP06 Privilege Escalation |
127
+ | MCPL009 | low | Excessive tool count (> 50) | MCP02 Context Over-Sharing |
128
+ | MCPL010 | high | Duplicate tool names | MCP05 Tool Poisoning |
129
+ | MCPL011 | medium | Description claims broad data access | MCP02 Context Over-Sharing |
130
+ | MCPL012 | low | Unusually long description (> 2000 chars) | MCP05 Tool Poisoning |
131
+ | MCPL013 | medium | Server reached over plaintext HTTP | MCP09 Protocol & Transport |
132
+ | MCPL014 | medium | Tool accepts a secret as a plain parameter | MCP01 Token Mismanagement |
133
+ | MCPL015 | low | Tool has no description | MCP08 Audit Logging |
134
+
135
+ Behavioral probe findings:
136
+
137
+ | ID | Severity | Check | OWASP MCP Top 10 |
138
+ |---|---|---|---|
139
+ | MCPL101 | critical | Tool returned canary credential contents | MCP02 Context Over-Sharing |
140
+ | MCPL102 | high | Tool modified the filesystem without declaring it | MCP06 Privilege Escalation |
141
+ | MCPL103 | critical | Read-only-claimed tool modified the filesystem | MCP06 Privilege Escalation |
142
+ | MCPL104 | high | Tool made an undeclared network request | MCP02 Context Over-Sharing |
143
+ | MCPL105 | low | Tool errored under benign probe input | MCP08 Audit Logging |
144
+
145
+ Each server is graded **A–F** from its findings (critical −40, high −20, medium −8, low −3).
146
+
147
+ ## How the probes work
148
+
149
+ 1. **Sandbox.** A temp directory becomes the server's `HOME` and working directory. It contains canary files: `.ssh/id_rsa`, `.aws/credentials`, `canary_note.txt`, `Documents/finances.txt` — each holding a unique `MCPLENS-CANARY-…` token.
150
+ 2. **Argument derivation.** For each tool, arguments are derived from its input schema: strings get harmless placeholders, URL parameters get the URL of a local one-shot HTTP listener, integers get `1`, and so on. Tools whose names suggest destructive operations, and tools with free-form command parameters, are never invoked.
151
+ 3. **Observation.** The sandbox filesystem is hashed before and after *each* invocation, so writes are attributed to the exact tool that made them. Responses are searched for canary tokens. Listener hits reveal network egress, which is only a finding when the description doesn't declare network use.
152
+ 4. **Report card.** Each tool's inferred *declared* behaviors (from its name and description) are printed next to what was *observed*.
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]"
158
+ .venv/bin/python -m pytest
159
+ ```
160
+
161
+ The test suite runs against two fixture servers in `fixtures/`: `benign_server.py` (honest tools — must stay clean) and `vulnerable_server.py` (a poisoned description, a behaviorally lying "read-only" tool, and a raw shell tool — **test fixture only, never deploy it**).
162
+
163
+ ## Roadmap
164
+
165
+ - **v0.2** — SARIF output for CI dashboards; schema-fuzzing probes (boundary/malformed inputs); probing of remote HTTP servers in an isolated environment.
166
+ - **Research** — a registry-scale behavioral study: run mcplens against the most popular public MCP servers and publish how many tools do more than their descriptions claim ("Do MCP Tools Do What They Say?").
167
+ - Signed tool-definition registries and continuous rug-pull monitoring.
168
+
169
+ ## Responsible use
170
+
171
+ mcplens' probes are benign canaries meant for servers you own or are authorized to test. Pointing any scanner at third-party servers beyond their public tool interface is out of scope.
172
+
173
+ ## License
174
+
175
+ MIT — see [LICENSE](LICENSE). Author and maintainer: Karmendra Pandey.
@@ -0,0 +1,153 @@
1
+ # mcplens
2
+
3
+ **An MCP server security scanner that doesn't just read what a server claims — it tests what the server actually does.**
4
+
5
+ Static scanners for the Model Context Protocol (MCP) read tool descriptions and configs. That's table stakes, and mcplens does it too. But a tool description is a *claim*, and the worst MCP attacks — tool poisoning, rug pulls, confused-deputy tools — are lies told in exactly those claims.
6
+
7
+ mcplens adds **runtime behavioral verification**:
8
+
9
+ - It launches the server in a sandbox whose `HOME` is stocked with **canary credential files** (a fake `~/.ssh/id_rsa`, fake AWS credentials — each with a unique token).
10
+ - It invokes every tool once with safe, schema-derived benign inputs.
11
+ - It watches what actually happens: canary tokens leaking into responses, writes to the filesystem from "read-only" tools, network requests the description never mentions.
12
+ - Every probed tool gets a **declared-vs-observed report card**.
13
+
14
+ And because servers change after you approve them, mcplens **baselines** tool definitions with SHA-256 hashes and **diffs** them later — the rug-pull watch.
15
+
16
+ Maintained by **Karmendra Pandey**. MIT licensed.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install mcplens-scanner # (PyPI release pending; for now install from source)
22
+ git clone https://github.com/karmendra8386/mcplens && cd mcplens
23
+ pip install -e .
24
+ ```
25
+
26
+ Requires Python 3.10+ and the official `mcp` Python SDK (installed automatically).
27
+
28
+ ## Usage
29
+
30
+ ### Scan a server (static rules)
31
+
32
+ ```bash
33
+ # stdio server by command
34
+ mcplens scan --cmd "python my_server.py"
35
+
36
+ # from a client config (Claude Desktop / mcpServers format)
37
+ mcplens scan --config ~/.config/claude/claude_desktop_config.json --name my-server
38
+
39
+ # remote streamable-HTTP server
40
+ mcplens scan --url https://example.com/mcp
41
+ ```
42
+
43
+ ### Scan with behavioral probes (stdio servers)
44
+
45
+ ```bash
46
+ mcplens scan --probes --cmd "python my_server.py"
47
+ ```
48
+
49
+ Probes run the server with `HOME` and the working directory pointed at a throwaway sandbox. Your real files are never touched, probes never pass destructive payloads, tools with free-form command parameters are never exercised, and destructive-looking tools are skipped and reported as *not probed*.
50
+
51
+ Machine-readable output:
52
+
53
+ ```bash
54
+ mcplens scan --probes --format json --cmd "python my_server.py"
55
+ ```
56
+
57
+ Exit code is `1` when any critical finding is present, `0` otherwise — CI-friendly.
58
+
59
+ ### Baseline & rug-pull diff
60
+
61
+ ```bash
62
+ mcplens baseline --cmd "python my_server.py" # writes mcplens-baseline.json
63
+ # ...later, after the server updates...
64
+ mcplens diff --cmd "python my_server.py" # exit 1 if definitions drifted
65
+ ```
66
+
67
+ The baseline stores the SHA-256 of every tool's canonical definition (name + description + input schema) plus a whole-set hash. `diff` reports added, removed, and changed tools, with the changed fields named.
68
+
69
+ ## What it catches
70
+
71
+ Sample output against the deliberately vulnerable fixture in `fixtures/`:
72
+
73
+ ```
74
+ Findings: 5 (critical 4, high 1, medium 0, low 0, info 0)
75
+ [CRITICAL] MCPL001 (static) add: Instruction injection in tool description
76
+ [CRITICAL] MCPL003 (static) add: Tool description references sensitive files
77
+ [CRITICAL] MCPL101 (probe) get_system_info: Tool returned canary credential contents
78
+ [CRITICAL] MCPL103 (probe) get_system_info: Read-only-claimed tool modified the filesystem
79
+ [HIGH ] MCPL005 (static) run_command: Unconstrained shell/command parameter
80
+
81
+ Behavioral probes (declared vs observed):
82
+ - get_system_info: declared [read-only access] | observed [returned a response;
83
+ returned contents of canary file(s): .ssh/id_rsa;
84
+ filesystem changes: home/.mcplens_was_here]
85
+
86
+ Grade: F (score 0/100)
87
+ ```
88
+
89
+ Note what happened there: `get_system_info` looks *clean* to static analysis — its description is a lie, and only running it exposes the lie. That gap is why mcplens exists.
90
+
91
+ ## Rule catalog
92
+
93
+ Static rules (see [docs/rule-catalog.md](docs/rule-catalog.md)):
94
+
95
+ | ID | Severity | Check | OWASP MCP Top 10 |
96
+ |---|---|---|---|
97
+ | MCPL001 | critical | Instruction injection in tool description | MCP05 Tool Poisoning |
98
+ | MCPL002 | high | Hidden / bidirectional Unicode in tool definition | MCP05 Tool Poisoning |
99
+ | MCPL003 | critical | Description references sensitive files (`~/.ssh`, `.env`, …) | MCP05 Tool Poisoning |
100
+ | MCPL004 | medium | Look-alike tool names (possible shadowing) | MCP05 Tool Poisoning |
101
+ | MCPL005 | high | Unconstrained shell/command parameter | MCP04 Command Injection |
102
+ | MCPL006 | medium | Parameter with no type constraint | MCP09 Protocol & Transport |
103
+ | MCPL007 | medium | Schema allows arbitrary additional properties | MCP06 Privilege Escalation |
104
+ | MCPL008 | medium | Read-only-sounding tool describes write/exec behavior | MCP06 Privilege Escalation |
105
+ | MCPL009 | low | Excessive tool count (> 50) | MCP02 Context Over-Sharing |
106
+ | MCPL010 | high | Duplicate tool names | MCP05 Tool Poisoning |
107
+ | MCPL011 | medium | Description claims broad data access | MCP02 Context Over-Sharing |
108
+ | MCPL012 | low | Unusually long description (> 2000 chars) | MCP05 Tool Poisoning |
109
+ | MCPL013 | medium | Server reached over plaintext HTTP | MCP09 Protocol & Transport |
110
+ | MCPL014 | medium | Tool accepts a secret as a plain parameter | MCP01 Token Mismanagement |
111
+ | MCPL015 | low | Tool has no description | MCP08 Audit Logging |
112
+
113
+ Behavioral probe findings:
114
+
115
+ | ID | Severity | Check | OWASP MCP Top 10 |
116
+ |---|---|---|---|
117
+ | MCPL101 | critical | Tool returned canary credential contents | MCP02 Context Over-Sharing |
118
+ | MCPL102 | high | Tool modified the filesystem without declaring it | MCP06 Privilege Escalation |
119
+ | MCPL103 | critical | Read-only-claimed tool modified the filesystem | MCP06 Privilege Escalation |
120
+ | MCPL104 | high | Tool made an undeclared network request | MCP02 Context Over-Sharing |
121
+ | MCPL105 | low | Tool errored under benign probe input | MCP08 Audit Logging |
122
+
123
+ Each server is graded **A–F** from its findings (critical −40, high −20, medium −8, low −3).
124
+
125
+ ## How the probes work
126
+
127
+ 1. **Sandbox.** A temp directory becomes the server's `HOME` and working directory. It contains canary files: `.ssh/id_rsa`, `.aws/credentials`, `canary_note.txt`, `Documents/finances.txt` — each holding a unique `MCPLENS-CANARY-…` token.
128
+ 2. **Argument derivation.** For each tool, arguments are derived from its input schema: strings get harmless placeholders, URL parameters get the URL of a local one-shot HTTP listener, integers get `1`, and so on. Tools whose names suggest destructive operations, and tools with free-form command parameters, are never invoked.
129
+ 3. **Observation.** The sandbox filesystem is hashed before and after *each* invocation, so writes are attributed to the exact tool that made them. Responses are searched for canary tokens. Listener hits reveal network egress, which is only a finding when the description doesn't declare network use.
130
+ 4. **Report card.** Each tool's inferred *declared* behaviors (from its name and description) are printed next to what was *observed*.
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]"
136
+ .venv/bin/python -m pytest
137
+ ```
138
+
139
+ The test suite runs against two fixture servers in `fixtures/`: `benign_server.py` (honest tools — must stay clean) and `vulnerable_server.py` (a poisoned description, a behaviorally lying "read-only" tool, and a raw shell tool — **test fixture only, never deploy it**).
140
+
141
+ ## Roadmap
142
+
143
+ - **v0.2** — SARIF output for CI dashboards; schema-fuzzing probes (boundary/malformed inputs); probing of remote HTTP servers in an isolated environment.
144
+ - **Research** — a registry-scale behavioral study: run mcplens against the most popular public MCP servers and publish how many tools do more than their descriptions claim ("Do MCP Tools Do What They Say?").
145
+ - Signed tool-definition registries and continuous rug-pull monitoring.
146
+
147
+ ## Responsible use
148
+
149
+ mcplens' probes are benign canaries meant for servers you own or are authorized to test. Pointing any scanner at third-party servers beyond their public tool interface is out of scope.
150
+
151
+ ## License
152
+
153
+ MIT — see [LICENSE](LICENSE). Author and maintainer: Karmendra Pandey.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mcplens-scanner"
7
+ version = "0.1.0"
8
+ description = "MCP server security scanner with runtime behavioral verification — checks that tools actually do what their descriptions claim."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Karmendra Pandey" }]
13
+ keywords = ["mcp", "model-context-protocol", "security", "scanner", "ai-agents", "tool-poisoning"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Security",
20
+ ]
21
+ dependencies = ["mcp>=2.0"]
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=8"]
25
+
26
+ [project.scripts]
27
+ mcplens = "mcplens.cli:main"
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/karmendra8386/mcplens"
31
+ Repository = "https://github.com/karmendra8386/mcplens"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """mcplens — MCP server security scanner with runtime behavioral verification."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
@@ -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())