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.
- mcplens_scanner-0.1.0/LICENSE +21 -0
- mcplens_scanner-0.1.0/PKG-INFO +175 -0
- mcplens_scanner-0.1.0/README.md +153 -0
- mcplens_scanner-0.1.0/pyproject.toml +37 -0
- mcplens_scanner-0.1.0/setup.cfg +4 -0
- mcplens_scanner-0.1.0/src/mcplens/__init__.py +3 -0
- mcplens_scanner-0.1.0/src/mcplens/baseline.py +102 -0
- mcplens_scanner-0.1.0/src/mcplens/cli.py +157 -0
- mcplens_scanner-0.1.0/src/mcplens/models.py +116 -0
- mcplens_scanner-0.1.0/src/mcplens/probes.py +315 -0
- mcplens_scanner-0.1.0/src/mcplens/report.py +72 -0
- mcplens_scanner-0.1.0/src/mcplens/rules.py +231 -0
- mcplens_scanner-0.1.0/src/mcplens/sandbox.py +96 -0
- mcplens_scanner-0.1.0/src/mcplens/transport.py +125 -0
- mcplens_scanner-0.1.0/src/mcplens_scanner.egg-info/PKG-INFO +175 -0
- mcplens_scanner-0.1.0/src/mcplens_scanner.egg-info/SOURCES.txt +21 -0
- mcplens_scanner-0.1.0/src/mcplens_scanner.egg-info/dependency_links.txt +1 -0
- mcplens_scanner-0.1.0/src/mcplens_scanner.egg-info/entry_points.txt +2 -0
- mcplens_scanner-0.1.0/src/mcplens_scanner.egg-info/requires.txt +4 -0
- mcplens_scanner-0.1.0/src/mcplens_scanner.egg-info/top_level.txt +1 -0
- mcplens_scanner-0.1.0/tests/test_baseline.py +68 -0
- mcplens_scanner-0.1.0/tests/test_probes.py +42 -0
- mcplens_scanner-0.1.0/tests/test_rules.py +38 -0
|
@@ -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,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())
|