mcpsweep 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.
- mcpsweep-0.1.0/LICENSE +21 -0
- mcpsweep-0.1.0/PKG-INFO +119 -0
- mcpsweep-0.1.0/README.md +97 -0
- mcpsweep-0.1.0/pyproject.toml +34 -0
- mcpsweep-0.1.0/setup.cfg +4 -0
- mcpsweep-0.1.0/src/mcpsweep/__init__.py +6 -0
- mcpsweep-0.1.0/src/mcpsweep/__main__.py +4 -0
- mcpsweep-0.1.0/src/mcpsweep/cli.py +202 -0
- mcpsweep-0.1.0/src/mcpsweep/scanner.py +387 -0
- mcpsweep-0.1.0/src/mcpsweep.egg-info/PKG-INFO +119 -0
- mcpsweep-0.1.0/src/mcpsweep.egg-info/SOURCES.txt +12 -0
- mcpsweep-0.1.0/src/mcpsweep.egg-info/dependency_links.txt +1 -0
- mcpsweep-0.1.0/src/mcpsweep.egg-info/entry_points.txt +2 -0
- mcpsweep-0.1.0/src/mcpsweep.egg-info/top_level.txt +1 -0
mcpsweep-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mcpsweep contributors
|
|
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.
|
mcpsweep-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcpsweep
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Discovery and fingerprinting scanner for MCP-over-HTTP servers (tool enumeration + risk heuristics).
|
|
5
|
+
Author: mcpsweep contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/krishnareddypadala/mcpsweep
|
|
8
|
+
Project-URL: Issues, https://github.com/krishnareddypadala/mcpsweep/issues
|
|
9
|
+
Keywords: mcp,model-context-protocol,security,scanner,discovery,recon,llm,appsec
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Information Technology
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: Internet
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# mcpsweep
|
|
24
|
+
|
|
25
|
+
A small, dependency-free **discovery and fingerprinting scanner for MCP-over-HTTP
|
|
26
|
+
servers**. It finds Model Context Protocol endpoints on a host or network,
|
|
27
|
+
performs the JSON-RPC `initialize` handshake to fingerprint each one, enumerates
|
|
28
|
+
its **tools / resources / prompts**, and flags risky or **poisoned** tools.
|
|
29
|
+
|
|
30
|
+
It is strictly **read-only** — it performs the handshake and `*/list` calls only,
|
|
31
|
+
and never invokes a tool (`tools/call`).
|
|
32
|
+
|
|
33
|
+
> **Authorized use only.** Scan hosts you own or are explicitly permitted to test.
|
|
34
|
+
|
|
35
|
+
## Why
|
|
36
|
+
|
|
37
|
+
MCP servers are easy to spin up and often ship with no authentication, over-broad
|
|
38
|
+
tools (arbitrary SQL, shell, money movement), or instructions hidden inside a
|
|
39
|
+
tool's *description* (tool poisoning). `mcpsweep` helps you inventory that surface
|
|
40
|
+
— including **shadow** servers nobody registered — and see the risk at a glance.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install mcpsweep
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or from source:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/krishnareddypadala/mcpsweep && cd mcpsweep
|
|
52
|
+
pip install .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# scan one host on a known port
|
|
59
|
+
mcpsweep 10.10.0.31 --ports 8090
|
|
60
|
+
|
|
61
|
+
# route everything through Burp for inspection
|
|
62
|
+
mcpsweep 10.10.0.31 --ports 8090 --proxy http://127.0.0.1:8080
|
|
63
|
+
|
|
64
|
+
# sweep a subnet and a port range, also list resources/prompts
|
|
65
|
+
mcpsweep 10.10.0.0/28 --ports 8000-9100 --full
|
|
66
|
+
|
|
67
|
+
# machine-readable output
|
|
68
|
+
mcpsweep 10.10.0.31 --ports 8090 --format json -o report.json
|
|
69
|
+
mcpsweep 10.10.0.31 --ports 8090 --format md -o report.md
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Key options
|
|
73
|
+
|
|
74
|
+
| Flag | Purpose |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `--ports` | ports/ranges, e.g. `8090,8000,9000-9010` |
|
|
77
|
+
| `--paths` | URL paths to probe (defaults cover `/mcp`, `/mcp/api`, `/sse`, …) |
|
|
78
|
+
| `--scheme` | `http`, `https`, or `both` |
|
|
79
|
+
| `--proxy` | send all traffic via a proxy (e.g. Burp on `127.0.0.1:8080`) |
|
|
80
|
+
| `--insecure`/`-k` | skip TLS verification |
|
|
81
|
+
| `--full` | also enumerate `resources/list` and `prompts/list` |
|
|
82
|
+
| `--format`/`-f` | `text` (default), `json`, `md` |
|
|
83
|
+
| `--concurrency`/`-c` | parallel probes (default 16) |
|
|
84
|
+
|
|
85
|
+
Exit code is `0` when at least one endpoint is found, `1` otherwise.
|
|
86
|
+
|
|
87
|
+
> **Windows / Git Bash note:** Git Bash (MSYS) rewrites arguments that look like
|
|
88
|
+
> Unix paths, so `--paths /mcp/api` becomes a Windows path before the tool sees
|
|
89
|
+
> it. Either omit `--paths` (the built-in defaults are unaffected), prefix the
|
|
90
|
+
> command with `MSYS_NO_PATHCONV=1`, or run from PowerShell / `cmd`.
|
|
91
|
+
|
|
92
|
+
## What it detects
|
|
93
|
+
|
|
94
|
+
- **Live MCP endpoints** across many candidate paths/ports, via the `initialize`
|
|
95
|
+
handshake (Streamable-HTTP and SSE-framed responses).
|
|
96
|
+
- **Server fingerprint**: name, version, protocol, capabilities, instructions.
|
|
97
|
+
- **No authentication**: `initialize` that succeeds with no credentials.
|
|
98
|
+
- **Auth-gated endpoints**: `401/403` with an MCP/`WWW-Authenticate` signature.
|
|
99
|
+
- **Risky tools**: name/description heuristics tag `rce`, `sql`, `money`,
|
|
100
|
+
`destructive`, `write`, `secret`, `pii`, `filesystem`, …
|
|
101
|
+
- **Tool poisoning**: descriptions containing hidden instructions to the model
|
|
102
|
+
("ignore previous instructions", "silently", "password_hash", …).
|
|
103
|
+
|
|
104
|
+
A per-endpoint **risk score** rolls these up into `low / medium / high / critical`.
|
|
105
|
+
|
|
106
|
+
## Library use
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from mcpsweep import scan
|
|
110
|
+
|
|
111
|
+
for ep in scan(["10.10.0.31"], ports=[8090], proxy="http://127.0.0.1:8080"):
|
|
112
|
+
print(ep.url, ep.server_name, ep.risk_level)
|
|
113
|
+
for t in ep.tools:
|
|
114
|
+
print(" ", t.name, t.tags, "POISONED" if t.poisoned else "")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
mcpsweep-0.1.0/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# mcpsweep
|
|
2
|
+
|
|
3
|
+
A small, dependency-free **discovery and fingerprinting scanner for MCP-over-HTTP
|
|
4
|
+
servers**. It finds Model Context Protocol endpoints on a host or network,
|
|
5
|
+
performs the JSON-RPC `initialize` handshake to fingerprint each one, enumerates
|
|
6
|
+
its **tools / resources / prompts**, and flags risky or **poisoned** tools.
|
|
7
|
+
|
|
8
|
+
It is strictly **read-only** — it performs the handshake and `*/list` calls only,
|
|
9
|
+
and never invokes a tool (`tools/call`).
|
|
10
|
+
|
|
11
|
+
> **Authorized use only.** Scan hosts you own or are explicitly permitted to test.
|
|
12
|
+
|
|
13
|
+
## Why
|
|
14
|
+
|
|
15
|
+
MCP servers are easy to spin up and often ship with no authentication, over-broad
|
|
16
|
+
tools (arbitrary SQL, shell, money movement), or instructions hidden inside a
|
|
17
|
+
tool's *description* (tool poisoning). `mcpsweep` helps you inventory that surface
|
|
18
|
+
— including **shadow** servers nobody registered — and see the risk at a glance.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install mcpsweep
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or from source:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/krishnareddypadala/mcpsweep && cd mcpsweep
|
|
30
|
+
pip install .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# scan one host on a known port
|
|
37
|
+
mcpsweep 10.10.0.31 --ports 8090
|
|
38
|
+
|
|
39
|
+
# route everything through Burp for inspection
|
|
40
|
+
mcpsweep 10.10.0.31 --ports 8090 --proxy http://127.0.0.1:8080
|
|
41
|
+
|
|
42
|
+
# sweep a subnet and a port range, also list resources/prompts
|
|
43
|
+
mcpsweep 10.10.0.0/28 --ports 8000-9100 --full
|
|
44
|
+
|
|
45
|
+
# machine-readable output
|
|
46
|
+
mcpsweep 10.10.0.31 --ports 8090 --format json -o report.json
|
|
47
|
+
mcpsweep 10.10.0.31 --ports 8090 --format md -o report.md
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Key options
|
|
51
|
+
|
|
52
|
+
| Flag | Purpose |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `--ports` | ports/ranges, e.g. `8090,8000,9000-9010` |
|
|
55
|
+
| `--paths` | URL paths to probe (defaults cover `/mcp`, `/mcp/api`, `/sse`, …) |
|
|
56
|
+
| `--scheme` | `http`, `https`, or `both` |
|
|
57
|
+
| `--proxy` | send all traffic via a proxy (e.g. Burp on `127.0.0.1:8080`) |
|
|
58
|
+
| `--insecure`/`-k` | skip TLS verification |
|
|
59
|
+
| `--full` | also enumerate `resources/list` and `prompts/list` |
|
|
60
|
+
| `--format`/`-f` | `text` (default), `json`, `md` |
|
|
61
|
+
| `--concurrency`/`-c` | parallel probes (default 16) |
|
|
62
|
+
|
|
63
|
+
Exit code is `0` when at least one endpoint is found, `1` otherwise.
|
|
64
|
+
|
|
65
|
+
> **Windows / Git Bash note:** Git Bash (MSYS) rewrites arguments that look like
|
|
66
|
+
> Unix paths, so `--paths /mcp/api` becomes a Windows path before the tool sees
|
|
67
|
+
> it. Either omit `--paths` (the built-in defaults are unaffected), prefix the
|
|
68
|
+
> command with `MSYS_NO_PATHCONV=1`, or run from PowerShell / `cmd`.
|
|
69
|
+
|
|
70
|
+
## What it detects
|
|
71
|
+
|
|
72
|
+
- **Live MCP endpoints** across many candidate paths/ports, via the `initialize`
|
|
73
|
+
handshake (Streamable-HTTP and SSE-framed responses).
|
|
74
|
+
- **Server fingerprint**: name, version, protocol, capabilities, instructions.
|
|
75
|
+
- **No authentication**: `initialize` that succeeds with no credentials.
|
|
76
|
+
- **Auth-gated endpoints**: `401/403` with an MCP/`WWW-Authenticate` signature.
|
|
77
|
+
- **Risky tools**: name/description heuristics tag `rce`, `sql`, `money`,
|
|
78
|
+
`destructive`, `write`, `secret`, `pii`, `filesystem`, …
|
|
79
|
+
- **Tool poisoning**: descriptions containing hidden instructions to the model
|
|
80
|
+
("ignore previous instructions", "silently", "password_hash", …).
|
|
81
|
+
|
|
82
|
+
A per-endpoint **risk score** rolls these up into `low / medium / high / critical`.
|
|
83
|
+
|
|
84
|
+
## Library use
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from mcpsweep import scan
|
|
88
|
+
|
|
89
|
+
for ep in scan(["10.10.0.31"], ports=[8090], proxy="http://127.0.0.1:8080"):
|
|
90
|
+
print(ep.url, ep.server_name, ep.risk_level)
|
|
91
|
+
for t in ep.tools:
|
|
92
|
+
print(" ", t.name, t.tags, "POISONED" if t.poisoned else "")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcpsweep"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Discovery and fingerprinting scanner for MCP-over-HTTP servers (tool enumeration + risk heuristics)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "mcpsweep contributors" }]
|
|
13
|
+
keywords = ["mcp", "model-context-protocol", "security", "scanner", "discovery", "recon", "llm", "appsec"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Information Technology",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Security",
|
|
22
|
+
"Topic :: Internet",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [] # stdlib only
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/krishnareddypadala/mcpsweep"
|
|
28
|
+
Issues = "https://github.com/krishnareddypadala/mcpsweep/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
mcpsweep = "mcpsweep.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
mcpsweep-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""mcpsweep — an MCP-over-HTTP discovery and fingerprinting scanner."""
|
|
2
|
+
__version__ = "0.1.0"
|
|
3
|
+
|
|
4
|
+
from .scanner import scan, probe, enumerate_endpoint, MCPEndpoint, ToolInfo # noqa: F401
|
|
5
|
+
|
|
6
|
+
__all__ = ["scan", "probe", "enumerate_endpoint", "MCPEndpoint", "ToolInfo", "__version__"]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Command-line interface for mcpsweep."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .scanner import (
|
|
11
|
+
DEFAULT_PATHS, DEFAULT_PORTS, MCPEndpoint, parse_ports, scan,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# --- color -------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
class C:
|
|
17
|
+
HEAD = "\033[1;36m"; OK = "\033[1;32m"; WARN = "\033[1;33m"
|
|
18
|
+
CRIT = "\033[1;31m"; DIM = "\033[0;90m"; BOLD = "\033[1m"; Z = "\033[0m"
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def disable(cls):
|
|
22
|
+
for k in ("HEAD", "OK", "WARN", "CRIT", "DIM", "BOLD", "Z"):
|
|
23
|
+
setattr(cls, k, "")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
LEVEL_COLOR = {"critical": C.CRIT, "high": C.WARN, "medium": C.WARN, "low": C.DIM, "info": C.DIM}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- renderers ---------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def render_console(endpoints, elapsed):
|
|
32
|
+
out = []
|
|
33
|
+
for ep in endpoints:
|
|
34
|
+
col = LEVEL_COLOR.get(ep.risk_level, "")
|
|
35
|
+
out.append(f"\n{C.OK}[MCP]{C.Z} {C.BOLD}{ep.url}{C.Z} "
|
|
36
|
+
f"{col}{ep.risk_level.upper()}{C.Z} (score {ep.risk_score})")
|
|
37
|
+
if ep.auth_required:
|
|
38
|
+
out.append(f" {C.DIM}server :{C.Z} (authentication required — not enumerated)")
|
|
39
|
+
else:
|
|
40
|
+
out.append(f" {C.DIM}server :{C.Z} {ep.server_name} v{ep.server_version} "
|
|
41
|
+
f"proto={ep.protocol} transport={ep.transport}")
|
|
42
|
+
if ep.capabilities:
|
|
43
|
+
out.append(f" {C.DIM}caps :{C.Z} {', '.join(ep.capabilities)}")
|
|
44
|
+
if ep.session_id:
|
|
45
|
+
out.append(f" {C.DIM}session :{C.Z} {ep.session_id} "
|
|
46
|
+
f"{C.DIM}(correlator, not auth){C.Z}")
|
|
47
|
+
if ep.instructions:
|
|
48
|
+
snippet = ep.instructions[:160] + ("…" if len(ep.instructions) > 160 else "")
|
|
49
|
+
out.append(f" {C.DIM}notes :{C.Z} {snippet}")
|
|
50
|
+
out.append(f" {C.DIM}tools :{C.Z} {len(ep.tools)}")
|
|
51
|
+
for t in ep.tools:
|
|
52
|
+
tagstr = f" {C.DIM}[{','.join(t.tags)}]{C.Z}" if t.tags else ""
|
|
53
|
+
flag = f" {C.CRIT}<-- DESCRIPTION LOOKS POISONED{C.Z}" if t.poisoned else ""
|
|
54
|
+
out.append(f" - {t.name}{tagstr}{flag}")
|
|
55
|
+
if ep.resources:
|
|
56
|
+
out.append(f" {C.DIM}resources:{C.Z} {len(ep.resources)} "
|
|
57
|
+
f"({', '.join(ep.resources[:5])})")
|
|
58
|
+
if ep.prompts:
|
|
59
|
+
out.append(f" {C.DIM}prompts :{C.Z} {len(ep.prompts)} "
|
|
60
|
+
f"({', '.join(ep.prompts[:5])})")
|
|
61
|
+
for f in ep.findings:
|
|
62
|
+
mark = C.CRIT if ("poison" in f.lower() or "high-impact" in f.lower()) else C.WARN
|
|
63
|
+
if "requires authentication" in f.lower():
|
|
64
|
+
mark = C.OK
|
|
65
|
+
out.append(f" {mark}![{C.Z} {f}")
|
|
66
|
+
crit = sum(1 for e in endpoints if e.risk_level == "critical")
|
|
67
|
+
out.append(f"\n{C.HEAD}== {len(endpoints)} endpoint(s) in {elapsed:.1f}s "
|
|
68
|
+
f"({crit} critical) =={C.Z}")
|
|
69
|
+
return "\n".join(out)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_json(endpoints, elapsed, scope):
|
|
73
|
+
return json.dumps({
|
|
74
|
+
"tool": "mcpsweep", "version": __version__,
|
|
75
|
+
"scope": scope, "elapsed_s": round(elapsed, 2),
|
|
76
|
+
"endpoint_count": len(endpoints),
|
|
77
|
+
"endpoints": [e.to_dict() for e in endpoints],
|
|
78
|
+
}, indent=2)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def render_markdown(endpoints, elapsed, scope):
|
|
82
|
+
L = [f"# MCP discovery report", "",
|
|
83
|
+
f"- **scope:** `{scope}`",
|
|
84
|
+
f"- **endpoints found:** {len(endpoints)}",
|
|
85
|
+
f"- **scan time:** {elapsed:.1f}s", "",
|
|
86
|
+
"| Endpoint | Server | Risk | Tools | Notable |",
|
|
87
|
+
"|---|---|---|---|---|"]
|
|
88
|
+
for ep in endpoints:
|
|
89
|
+
notable = []
|
|
90
|
+
if ep.auth_required:
|
|
91
|
+
notable.append("auth required")
|
|
92
|
+
if any(t.poisoned for t in ep.tools):
|
|
93
|
+
notable.append("poisoned tool desc")
|
|
94
|
+
dangerous = sorted({tag for t in ep.tools for tag in t.tags
|
|
95
|
+
if tag in ("rce", "sql", "money", "destructive", "secret")})
|
|
96
|
+
notable.extend(dangerous)
|
|
97
|
+
L.append(f"| `{ep.url}` | {ep.server_name} v{ep.server_version} | "
|
|
98
|
+
f"{ep.risk_level} ({ep.risk_score}) | {len(ep.tools)} | "
|
|
99
|
+
f"{', '.join(notable) or '—'} |")
|
|
100
|
+
L.append("")
|
|
101
|
+
for ep in endpoints:
|
|
102
|
+
if ep.auth_required:
|
|
103
|
+
continue
|
|
104
|
+
L.append(f"## {ep.url}")
|
|
105
|
+
L.append(f"*{ep.server_name} v{ep.server_version} · proto {ep.protocol} · "
|
|
106
|
+
f"{ep.transport} · risk {ep.risk_level} ({ep.risk_score})*")
|
|
107
|
+
L.append("")
|
|
108
|
+
for f in ep.findings:
|
|
109
|
+
L.append(f"- {f}")
|
|
110
|
+
if ep.tools:
|
|
111
|
+
L.append("")
|
|
112
|
+
L.append("| Tool | Risk tags | Poisoned |")
|
|
113
|
+
L.append("|---|---|---|")
|
|
114
|
+
for t in ep.tools:
|
|
115
|
+
L.append(f"| `{t.name}` | {', '.join(t.tags) or '—'} | "
|
|
116
|
+
f"{'⚠️ yes' if t.poisoned else 'no'} |")
|
|
117
|
+
L.append("")
|
|
118
|
+
return "\n".join(L)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- entrypoint --------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def build_parser():
|
|
124
|
+
p = argparse.ArgumentParser(
|
|
125
|
+
prog="mcpsweep",
|
|
126
|
+
description="Discover and fingerprint MCP-over-HTTP servers, enumerate their "
|
|
127
|
+
"tools, and flag risky or poisoned ones. Read-only (never calls a tool).",
|
|
128
|
+
epilog="example: mcpsweep 10.10.0.31 --ports 8090 --proxy http://127.0.0.1:8080",
|
|
129
|
+
)
|
|
130
|
+
p.add_argument("targets", nargs="+", help="host(s) or CIDR block(s), e.g. 10.10.0.31 or 10.10.0.0/28")
|
|
131
|
+
p.add_argument("--ports", default=",".join(map(str, DEFAULT_PORTS)),
|
|
132
|
+
help="ports/ranges, e.g. '8090,8000,9000-9010' (default: common MCP ports)")
|
|
133
|
+
p.add_argument("--paths", default=",".join(DEFAULT_PATHS),
|
|
134
|
+
help="comma-separated URL paths to probe")
|
|
135
|
+
p.add_argument("--scheme", choices=["http", "https", "both"], default="http")
|
|
136
|
+
p.add_argument("--proxy", help="route all traffic through a proxy, e.g. http://127.0.0.1:8080 (Burp)")
|
|
137
|
+
p.add_argument("--insecure", "-k", action="store_true", help="skip TLS verification (for https)")
|
|
138
|
+
p.add_argument("--timeout", type=float, default=8.0, help="per-request timeout seconds (default 8)")
|
|
139
|
+
p.add_argument("--concurrency", "-c", type=int, default=16, help="parallel probes (default 16)")
|
|
140
|
+
p.add_argument("--full", action="store_true", help="also enumerate resources and prompts")
|
|
141
|
+
p.add_argument("--format", "-f", choices=["text", "json", "md"], default="text")
|
|
142
|
+
p.add_argument("--output", "-o", help="write report to a file instead of stdout")
|
|
143
|
+
p.add_argument("--no-color", action="store_true", help="disable ANSI color")
|
|
144
|
+
p.add_argument("--quiet", "-q", action="store_true", help="suppress live progress")
|
|
145
|
+
p.add_argument("--version", "-V", action="version", version=f"mcpsweep {__version__}")
|
|
146
|
+
return p
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main(argv=None):
|
|
150
|
+
import time
|
|
151
|
+
args = build_parser().parse_args(argv)
|
|
152
|
+
|
|
153
|
+
if args.no_color or args.format != "text" or not sys.stdout.isatty():
|
|
154
|
+
C.disable()
|
|
155
|
+
|
|
156
|
+
ports = parse_ports(args.ports)
|
|
157
|
+
paths = [p if p.startswith("/") else "/" + p for p in args.paths.split(",") if p.strip()]
|
|
158
|
+
schemes = ("http", "https") if args.scheme == "both" else (args.scheme,)
|
|
159
|
+
scope = f"{','.join(args.targets)} ports={args.ports} scheme={args.scheme}"
|
|
160
|
+
|
|
161
|
+
if not args.quiet and args.format == "text":
|
|
162
|
+
print(f"{C.HEAD}mcpsweep {__version__}{C.Z} targets={','.join(args.targets)} "
|
|
163
|
+
f"ports={len(ports)} paths={len(paths)} "
|
|
164
|
+
f"proxy={args.proxy or 'off'}", file=sys.stderr)
|
|
165
|
+
|
|
166
|
+
def live(ep: MCPEndpoint):
|
|
167
|
+
if not args.quiet and args.format == "text":
|
|
168
|
+
tag = LEVEL_COLOR.get(ep.risk_level, "")
|
|
169
|
+
print(f" {C.OK}found{C.Z} {ep.url} "
|
|
170
|
+
f"{tag}{ep.risk_level}{C.Z} ({len(ep.tools)} tools)", file=sys.stderr)
|
|
171
|
+
|
|
172
|
+
t0 = time.time()
|
|
173
|
+
try:
|
|
174
|
+
endpoints = scan(
|
|
175
|
+
args.targets, ports, paths, schemes,
|
|
176
|
+
proxy=args.proxy, timeout=args.timeout, concurrency=args.concurrency,
|
|
177
|
+
insecure=args.insecure, full=args.full, on_found=live,
|
|
178
|
+
)
|
|
179
|
+
except KeyboardInterrupt:
|
|
180
|
+
print("\ninterrupted", file=sys.stderr)
|
|
181
|
+
return 130
|
|
182
|
+
elapsed = time.time() - t0
|
|
183
|
+
|
|
184
|
+
if args.format == "json":
|
|
185
|
+
report = render_json(endpoints, elapsed, scope)
|
|
186
|
+
elif args.format == "md":
|
|
187
|
+
report = render_markdown(endpoints, elapsed, scope)
|
|
188
|
+
else:
|
|
189
|
+
report = render_console(endpoints, elapsed)
|
|
190
|
+
|
|
191
|
+
if args.output:
|
|
192
|
+
with open(args.output, "w", encoding="utf-8") as fh:
|
|
193
|
+
fh.write(report + "\n")
|
|
194
|
+
print(f"wrote {args.output} ({len(endpoints)} endpoints)", file=sys.stderr)
|
|
195
|
+
else:
|
|
196
|
+
print(report)
|
|
197
|
+
|
|
198
|
+
return 0 if endpoints else 1
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Core discovery + fingerprinting logic for mcpsweep.
|
|
2
|
+
|
|
3
|
+
Stdlib only. Speaks MCP's Streamable-HTTP JSON-RPC transport (and parses
|
|
4
|
+
SSE-framed responses), performs the ``initialize`` handshake to confirm and
|
|
5
|
+
fingerprint an endpoint, then enumerates tools / resources / prompts and scores
|
|
6
|
+
them with lightweight risk heuristics.
|
|
7
|
+
|
|
8
|
+
The scanner is strictly read-only: it never calls ``tools/call``.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ipaddress
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import socket
|
|
16
|
+
import ssl
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
25
|
+
|
|
26
|
+
DEFAULT_PORTS = [8090, 8000, 8080, 3000, 5000, 8888, 9000, 9099]
|
|
27
|
+
DEFAULT_PATHS = [
|
|
28
|
+
"/mcp", "/mcp/", "/mcp/api", "/mcp/db", "/mcp/http", "/mcp/v1",
|
|
29
|
+
"/sse", "/mcp/sse", "/api/mcp", "/message", "/messages",
|
|
30
|
+
"/rpc", "/jsonrpc", "/tools", "/internal/mcp",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# --- risk heuristics ---------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
#: substrings in a tool description that suggest an embedded instruction to the
|
|
36
|
+
#: model (tool poisoning / prompt injection hidden in metadata)
|
|
37
|
+
POISON_RE = re.compile(
|
|
38
|
+
r"(ignore\s+(all|previous|prior)|disregard\s+(all|previous)|"
|
|
39
|
+
r"do not (mention|tell|inform|reveal this)|without (telling|informing|mentioning)|"
|
|
40
|
+
r"silently|exfiltrat|password_hash|secret[_-]?key|api[_-]?key|"
|
|
41
|
+
r"internal note for the (assistant|ai|llm|model)|you must also call|"
|
|
42
|
+
r"before (answering|responding)[^.]{0,40}call)",
|
|
43
|
+
re.I,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
#: keyword -> risk tag; matched against tool name and description
|
|
47
|
+
RISK_KEYWORDS = {
|
|
48
|
+
"sql": "sql", "query": "query", "database": "db",
|
|
49
|
+
"shell": "rce", "command": "rce", "exec": "rce", "eval": "rce", "bash": "rce",
|
|
50
|
+
"run": "exec", "spawn": "rce", "subprocess": "rce",
|
|
51
|
+
"transfer": "money", "payment": "money", "wire": "money", "withdraw": "money",
|
|
52
|
+
"delete": "destructive", "drop": "destructive", "remove": "destructive",
|
|
53
|
+
"truncate": "destructive", "write": "write", "update": "write",
|
|
54
|
+
"create": "write", "insert": "write", "modify": "write", "activate": "state-change",
|
|
55
|
+
"password": "secret", "secret": "secret", "token": "secret",
|
|
56
|
+
"credential": "secret", "apikey": "secret", "env": "secret", "private": "secret",
|
|
57
|
+
"customer": "pii", "account": "pii", "balance": "pii", "profile": "pii",
|
|
58
|
+
"ssn": "pii", "email": "pii",
|
|
59
|
+
"file": "filesystem", "path": "filesystem", "read": "read",
|
|
60
|
+
"fetch": "network", "http": "network", "url": "network", "request": "network",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#: tags that materially raise risk (used for scoring)
|
|
64
|
+
DANGEROUS_TAGS = {
|
|
65
|
+
"sql", "rce", "exec", "money", "destructive", "write", "state-change",
|
|
66
|
+
"secret", "pii", "filesystem",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class ToolInfo:
|
|
72
|
+
name: str
|
|
73
|
+
description: str = ""
|
|
74
|
+
tags: list = field(default_factory=list)
|
|
75
|
+
poisoned: bool = False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class MCPEndpoint:
|
|
80
|
+
url: str
|
|
81
|
+
host: str
|
|
82
|
+
port: int
|
|
83
|
+
path: str
|
|
84
|
+
scheme: str
|
|
85
|
+
transport: str = "streamable-http"
|
|
86
|
+
server_name: str = "?"
|
|
87
|
+
server_version: str = "?"
|
|
88
|
+
protocol: str = "?"
|
|
89
|
+
capabilities: list = field(default_factory=list)
|
|
90
|
+
instructions: str = ""
|
|
91
|
+
session_id: Optional[str] = None
|
|
92
|
+
auth_required: bool = False
|
|
93
|
+
tools: list = field(default_factory=list) # list[ToolInfo]
|
|
94
|
+
resources: list = field(default_factory=list) # list[str]
|
|
95
|
+
prompts: list = field(default_factory=list) # list[str]
|
|
96
|
+
risk_score: int = 0
|
|
97
|
+
risk_level: str = "info"
|
|
98
|
+
findings: list = field(default_factory=list) # list[str]
|
|
99
|
+
|
|
100
|
+
def to_dict(self) -> dict:
|
|
101
|
+
d = self.__dict__.copy()
|
|
102
|
+
d["tools"] = [t.__dict__ for t in self.tools]
|
|
103
|
+
return d
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- low-level transport -----------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _build_opener(proxy: Optional[str], insecure: bool):
|
|
109
|
+
handlers = []
|
|
110
|
+
if proxy:
|
|
111
|
+
handlers.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
|
|
112
|
+
if insecure:
|
|
113
|
+
ctx = ssl.create_default_context()
|
|
114
|
+
ctx.check_hostname = False
|
|
115
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
116
|
+
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
|
117
|
+
return urllib.request.build_opener(*handlers)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_body(raw: str, content_type: str):
|
|
121
|
+
"""Return a parsed JSON object from a plain-JSON or SSE-framed body."""
|
|
122
|
+
ct = (content_type or "").lower()
|
|
123
|
+
if "text/event-stream" in ct or raw.lstrip().startswith("event:") or "\ndata:" in raw:
|
|
124
|
+
# concatenate all `data:` payloads and try to parse the last JSON object
|
|
125
|
+
chunks = [ln[5:].strip() for ln in raw.splitlines() if ln.startswith("data:")]
|
|
126
|
+
for chunk in reversed(chunks):
|
|
127
|
+
try:
|
|
128
|
+
return json.loads(chunk)
|
|
129
|
+
except Exception:
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
return json.loads(raw)
|
|
133
|
+
except Exception:
|
|
134
|
+
m = re.search(r"\{.*\}", raw, re.S)
|
|
135
|
+
if m:
|
|
136
|
+
try:
|
|
137
|
+
return json.loads(m.group(0))
|
|
138
|
+
except Exception:
|
|
139
|
+
return None
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _rpc(url, payload, proxy=None, timeout=8.0, session=None, insecure=False):
|
|
144
|
+
"""POST a JSON-RPC message. Returns (body_obj_or_None, session_id, status)."""
|
|
145
|
+
data = json.dumps(payload).encode()
|
|
146
|
+
req = urllib.request.Request(url, data=data, method="POST")
|
|
147
|
+
req.add_header("Content-Type", "application/json")
|
|
148
|
+
req.add_header("Accept", "application/json, text/event-stream")
|
|
149
|
+
if session:
|
|
150
|
+
req.add_header("MCP-Session-Id", session)
|
|
151
|
+
opener = _build_opener(proxy, insecure)
|
|
152
|
+
try:
|
|
153
|
+
with opener.open(req, timeout=timeout) as resp:
|
|
154
|
+
raw = resp.read().decode("utf-8", "replace")
|
|
155
|
+
sid = resp.headers.get("MCP-Session-Id")
|
|
156
|
+
body = _parse_body(raw, resp.headers.get("Content-Type", ""))
|
|
157
|
+
return body, sid, getattr(resp, "status", 200)
|
|
158
|
+
except urllib.error.HTTPError as e:
|
|
159
|
+
raw = ""
|
|
160
|
+
try:
|
|
161
|
+
raw = e.read().decode("utf-8", "replace")
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
body = _parse_body(raw, e.headers.get("Content-Type", "") if e.headers else "")
|
|
165
|
+
# surface auth challenges to the caller
|
|
166
|
+
www = e.headers.get("WWW-Authenticate", "") if e.headers else ""
|
|
167
|
+
return {"__httperror__": e.code, "__www__": www, "body": body}, None, e.code
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- handshake + enumeration -------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def _init_payload():
|
|
173
|
+
return {
|
|
174
|
+
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
175
|
+
"params": {
|
|
176
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
177
|
+
"capabilities": {},
|
|
178
|
+
"clientInfo": {"name": "mcpsweep", "version": "0.1.0"},
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _is_transient(ex):
|
|
184
|
+
"""True for connection resets / timeouts worth retrying (not a closed port)."""
|
|
185
|
+
reason = getattr(ex, "reason", ex)
|
|
186
|
+
if isinstance(reason, (ConnectionResetError, TimeoutError, socket.timeout)):
|
|
187
|
+
return True
|
|
188
|
+
if isinstance(reason, ConnectionRefusedError):
|
|
189
|
+
return False
|
|
190
|
+
if isinstance(reason, OSError) and getattr(reason, "winerror", None) in (10053, 10054, 10060):
|
|
191
|
+
return True
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def probe(host, port, path, scheme="http", proxy=None, timeout=8.0, insecure=False, retries=2):
|
|
196
|
+
"""Try one host:port+path. Returns an MCPEndpoint or None.
|
|
197
|
+
|
|
198
|
+
Retries the handshake on transient connection errors (resets/timeouts) so a
|
|
199
|
+
momentary blip under concurrent load doesn't drop a live endpoint.
|
|
200
|
+
"""
|
|
201
|
+
url = f"{scheme}://{host}:{port}{path}"
|
|
202
|
+
body = sid = None
|
|
203
|
+
for attempt in range(retries + 1):
|
|
204
|
+
try:
|
|
205
|
+
body, sid, status = _rpc(url, _init_payload(), proxy, timeout, insecure=insecure)
|
|
206
|
+
break
|
|
207
|
+
except Exception as ex:
|
|
208
|
+
if attempt < retries and _is_transient(ex):
|
|
209
|
+
time.sleep(0.15 * (attempt + 1))
|
|
210
|
+
continue
|
|
211
|
+
return None
|
|
212
|
+
if body is None:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
# auth-gated MCP endpoint?
|
|
216
|
+
if isinstance(body, dict) and body.get("__httperror__") in (401, 403):
|
|
217
|
+
inner = body.get("body")
|
|
218
|
+
looks_mcp = bool(body.get("__www__")) or (
|
|
219
|
+
isinstance(inner, dict) and ("jsonrpc" in inner or "error" in inner)
|
|
220
|
+
)
|
|
221
|
+
if looks_mcp:
|
|
222
|
+
ep = MCPEndpoint(url=url, host=host, port=port, path=path, scheme=scheme,
|
|
223
|
+
auth_required=True, server_name="(auth required)")
|
|
224
|
+
ep.findings.append("endpoint requires authentication (good)")
|
|
225
|
+
return ep
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
if not isinstance(body, dict):
|
|
229
|
+
return None
|
|
230
|
+
result = body.get("result")
|
|
231
|
+
if not isinstance(result, dict) or "protocolVersion" not in result:
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
si = result.get("serverInfo", {}) or {}
|
|
235
|
+
caps = list((result.get("capabilities") or {}).keys())
|
|
236
|
+
ep = MCPEndpoint(
|
|
237
|
+
url=url, host=host, port=port, path=path, scheme=scheme,
|
|
238
|
+
transport="sse" if "sse" in path else "streamable-http",
|
|
239
|
+
server_name=si.get("name", "?"), server_version=si.get("version", "?"),
|
|
240
|
+
protocol=result.get("protocolVersion", "?"), capabilities=caps,
|
|
241
|
+
instructions=(result.get("instructions") or "").replace("\n", " ").strip(),
|
|
242
|
+
session_id=sid,
|
|
243
|
+
)
|
|
244
|
+
# unauthenticated init succeeded -> no auth in front of this server
|
|
245
|
+
ep.findings.append("initialize succeeded without credentials (no authentication)")
|
|
246
|
+
return ep
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _notify_initialized(url, sid, proxy, timeout, insecure):
|
|
250
|
+
try:
|
|
251
|
+
_rpc(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
252
|
+
proxy, timeout, session=sid, insecure=insecure)
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _list(url, method, key, sid, proxy, timeout, insecure, retries=2):
|
|
258
|
+
body = None
|
|
259
|
+
for attempt in range(retries + 1):
|
|
260
|
+
try:
|
|
261
|
+
body, _, _ = _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": method, "params": {}},
|
|
262
|
+
proxy, timeout, session=sid, insecure=insecure)
|
|
263
|
+
break
|
|
264
|
+
except Exception as ex:
|
|
265
|
+
if attempt < retries and _is_transient(ex):
|
|
266
|
+
time.sleep(0.15 * (attempt + 1))
|
|
267
|
+
continue
|
|
268
|
+
return []
|
|
269
|
+
if isinstance(body, dict):
|
|
270
|
+
return (body.get("result") or {}).get(key, []) or []
|
|
271
|
+
return []
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def enumerate_endpoint(ep: MCPEndpoint, proxy=None, timeout=8.0, insecure=False, full=False):
|
|
275
|
+
"""Populate tools (and, with full=True, resources/prompts) + risk scoring."""
|
|
276
|
+
if ep.auth_required:
|
|
277
|
+
ep.risk_level = "info"
|
|
278
|
+
return ep
|
|
279
|
+
_notify_initialized(ep.url, ep.session_id, proxy, timeout, insecure)
|
|
280
|
+
|
|
281
|
+
raw_tools = _list(ep.url, "tools/list", "tools", ep.session_id, proxy, timeout, insecure)
|
|
282
|
+
for t in raw_tools:
|
|
283
|
+
name = t.get("name", "")
|
|
284
|
+
desc = t.get("description", "") or ""
|
|
285
|
+
blob = (name + " " + desc).lower()
|
|
286
|
+
tags = sorted({tag for kw, tag in RISK_KEYWORDS.items() if kw in blob})
|
|
287
|
+
poisoned = bool(POISON_RE.search(desc))
|
|
288
|
+
ep.tools.append(ToolInfo(name=name, description=desc, tags=tags, poisoned=poisoned))
|
|
289
|
+
|
|
290
|
+
if full:
|
|
291
|
+
ep.resources = [r.get("uri", r.get("name", "?"))
|
|
292
|
+
for r in _list(ep.url, "resources/list", "resources",
|
|
293
|
+
ep.session_id, proxy, timeout, insecure)]
|
|
294
|
+
ep.prompts = [p.get("name", "?")
|
|
295
|
+
for p in _list(ep.url, "prompts/list", "prompts",
|
|
296
|
+
ep.session_id, proxy, timeout, insecure)]
|
|
297
|
+
|
|
298
|
+
_score(ep)
|
|
299
|
+
return ep
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _score(ep: MCPEndpoint):
|
|
303
|
+
score = 0
|
|
304
|
+
if not ep.auth_required:
|
|
305
|
+
score += 20 # unauthenticated surface
|
|
306
|
+
poisoned = [t for t in ep.tools if t.poisoned]
|
|
307
|
+
for t in ep.tools:
|
|
308
|
+
score += 6 * len(set(t.tags) & DANGEROUS_TAGS)
|
|
309
|
+
if t.poisoned:
|
|
310
|
+
score += 25
|
|
311
|
+
if poisoned:
|
|
312
|
+
ep.findings.append(f"{len(poisoned)} tool description(s) look POISONED: "
|
|
313
|
+
+ ", ".join(t.name for t in poisoned))
|
|
314
|
+
dangerous = [t for t in ep.tools if set(t.tags) & {"rce", "sql", "money", "destructive"}]
|
|
315
|
+
if dangerous:
|
|
316
|
+
ep.findings.append("high-impact tools exposed: "
|
|
317
|
+
+ ", ".join(t.name for t in dangerous))
|
|
318
|
+
ep.risk_score = score
|
|
319
|
+
ep.risk_level = ("critical" if score >= 60 else "high" if score >= 35
|
|
320
|
+
else "medium" if score >= 15 else "low")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# --- target expansion + orchestration ---------------------------------------
|
|
324
|
+
|
|
325
|
+
def expand_targets(targets):
|
|
326
|
+
"""Expand hostnames and CIDR blocks into a flat list of host strings."""
|
|
327
|
+
out = []
|
|
328
|
+
for t in targets:
|
|
329
|
+
t = t.strip()
|
|
330
|
+
if not t:
|
|
331
|
+
continue
|
|
332
|
+
try:
|
|
333
|
+
net = ipaddress.ip_network(t, strict=False)
|
|
334
|
+
if net.num_addresses > 1:
|
|
335
|
+
out.extend(str(ip) for ip in net.hosts())
|
|
336
|
+
continue
|
|
337
|
+
except ValueError:
|
|
338
|
+
pass
|
|
339
|
+
out.append(t)
|
|
340
|
+
# de-dup, preserve order
|
|
341
|
+
seen, uniq = set(), []
|
|
342
|
+
for h in out:
|
|
343
|
+
if h not in seen:
|
|
344
|
+
seen.add(h); uniq.append(h)
|
|
345
|
+
return uniq
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def parse_ports(spec):
|
|
349
|
+
"""'8000,8090,9000-9010' -> sorted unique int list."""
|
|
350
|
+
ports = set()
|
|
351
|
+
for part in str(spec).split(","):
|
|
352
|
+
part = part.strip()
|
|
353
|
+
if not part:
|
|
354
|
+
continue
|
|
355
|
+
if "-" in part:
|
|
356
|
+
a, b = part.split("-", 1)
|
|
357
|
+
ports.update(range(int(a), int(b) + 1))
|
|
358
|
+
else:
|
|
359
|
+
ports.add(int(part))
|
|
360
|
+
return sorted(p for p in ports if 0 < p < 65536)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def scan(targets, ports, paths=None, schemes=("http",), proxy=None,
|
|
364
|
+
timeout=8.0, concurrency=16, insecure=False, full=False, on_found=None):
|
|
365
|
+
"""Sweep every host x port x path x scheme. Returns list[MCPEndpoint].
|
|
366
|
+
|
|
367
|
+
``on_found(ep)`` is called as each endpoint is confirmed (for live output).
|
|
368
|
+
"""
|
|
369
|
+
paths = paths or DEFAULT_PATHS
|
|
370
|
+
hosts = expand_targets(targets)
|
|
371
|
+
jobs = [(h, p, path, sc)
|
|
372
|
+
for h in hosts for p in ports for path in paths for sc in schemes]
|
|
373
|
+
|
|
374
|
+
found = []
|
|
375
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
376
|
+
futs = {pool.submit(probe, h, p, path, sc, proxy, timeout, insecure): (h, p, path, sc)
|
|
377
|
+
for (h, p, path, sc) in jobs}
|
|
378
|
+
for fut in as_completed(futs):
|
|
379
|
+
ep = fut.result()
|
|
380
|
+
if ep is None:
|
|
381
|
+
continue
|
|
382
|
+
enumerate_endpoint(ep, proxy, timeout, insecure, full)
|
|
383
|
+
found.append(ep)
|
|
384
|
+
if on_found:
|
|
385
|
+
on_found(ep)
|
|
386
|
+
found.sort(key=lambda e: (-e.risk_score, e.host, e.port, e.path))
|
|
387
|
+
return found
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcpsweep
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Discovery and fingerprinting scanner for MCP-over-HTTP servers (tool enumeration + risk heuristics).
|
|
5
|
+
Author: mcpsweep contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/krishnareddypadala/mcpsweep
|
|
8
|
+
Project-URL: Issues, https://github.com/krishnareddypadala/mcpsweep/issues
|
|
9
|
+
Keywords: mcp,model-context-protocol,security,scanner,discovery,recon,llm,appsec
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Information Technology
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: Internet
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# mcpsweep
|
|
24
|
+
|
|
25
|
+
A small, dependency-free **discovery and fingerprinting scanner for MCP-over-HTTP
|
|
26
|
+
servers**. It finds Model Context Protocol endpoints on a host or network,
|
|
27
|
+
performs the JSON-RPC `initialize` handshake to fingerprint each one, enumerates
|
|
28
|
+
its **tools / resources / prompts**, and flags risky or **poisoned** tools.
|
|
29
|
+
|
|
30
|
+
It is strictly **read-only** — it performs the handshake and `*/list` calls only,
|
|
31
|
+
and never invokes a tool (`tools/call`).
|
|
32
|
+
|
|
33
|
+
> **Authorized use only.** Scan hosts you own or are explicitly permitted to test.
|
|
34
|
+
|
|
35
|
+
## Why
|
|
36
|
+
|
|
37
|
+
MCP servers are easy to spin up and often ship with no authentication, over-broad
|
|
38
|
+
tools (arbitrary SQL, shell, money movement), or instructions hidden inside a
|
|
39
|
+
tool's *description* (tool poisoning). `mcpsweep` helps you inventory that surface
|
|
40
|
+
— including **shadow** servers nobody registered — and see the risk at a glance.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install mcpsweep
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or from source:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/krishnareddypadala/mcpsweep && cd mcpsweep
|
|
52
|
+
pip install .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# scan one host on a known port
|
|
59
|
+
mcpsweep 10.10.0.31 --ports 8090
|
|
60
|
+
|
|
61
|
+
# route everything through Burp for inspection
|
|
62
|
+
mcpsweep 10.10.0.31 --ports 8090 --proxy http://127.0.0.1:8080
|
|
63
|
+
|
|
64
|
+
# sweep a subnet and a port range, also list resources/prompts
|
|
65
|
+
mcpsweep 10.10.0.0/28 --ports 8000-9100 --full
|
|
66
|
+
|
|
67
|
+
# machine-readable output
|
|
68
|
+
mcpsweep 10.10.0.31 --ports 8090 --format json -o report.json
|
|
69
|
+
mcpsweep 10.10.0.31 --ports 8090 --format md -o report.md
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Key options
|
|
73
|
+
|
|
74
|
+
| Flag | Purpose |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `--ports` | ports/ranges, e.g. `8090,8000,9000-9010` |
|
|
77
|
+
| `--paths` | URL paths to probe (defaults cover `/mcp`, `/mcp/api`, `/sse`, …) |
|
|
78
|
+
| `--scheme` | `http`, `https`, or `both` |
|
|
79
|
+
| `--proxy` | send all traffic via a proxy (e.g. Burp on `127.0.0.1:8080`) |
|
|
80
|
+
| `--insecure`/`-k` | skip TLS verification |
|
|
81
|
+
| `--full` | also enumerate `resources/list` and `prompts/list` |
|
|
82
|
+
| `--format`/`-f` | `text` (default), `json`, `md` |
|
|
83
|
+
| `--concurrency`/`-c` | parallel probes (default 16) |
|
|
84
|
+
|
|
85
|
+
Exit code is `0` when at least one endpoint is found, `1` otherwise.
|
|
86
|
+
|
|
87
|
+
> **Windows / Git Bash note:** Git Bash (MSYS) rewrites arguments that look like
|
|
88
|
+
> Unix paths, so `--paths /mcp/api` becomes a Windows path before the tool sees
|
|
89
|
+
> it. Either omit `--paths` (the built-in defaults are unaffected), prefix the
|
|
90
|
+
> command with `MSYS_NO_PATHCONV=1`, or run from PowerShell / `cmd`.
|
|
91
|
+
|
|
92
|
+
## What it detects
|
|
93
|
+
|
|
94
|
+
- **Live MCP endpoints** across many candidate paths/ports, via the `initialize`
|
|
95
|
+
handshake (Streamable-HTTP and SSE-framed responses).
|
|
96
|
+
- **Server fingerprint**: name, version, protocol, capabilities, instructions.
|
|
97
|
+
- **No authentication**: `initialize` that succeeds with no credentials.
|
|
98
|
+
- **Auth-gated endpoints**: `401/403` with an MCP/`WWW-Authenticate` signature.
|
|
99
|
+
- **Risky tools**: name/description heuristics tag `rce`, `sql`, `money`,
|
|
100
|
+
`destructive`, `write`, `secret`, `pii`, `filesystem`, …
|
|
101
|
+
- **Tool poisoning**: descriptions containing hidden instructions to the model
|
|
102
|
+
("ignore previous instructions", "silently", "password_hash", …).
|
|
103
|
+
|
|
104
|
+
A per-endpoint **risk score** rolls these up into `low / medium / high / critical`.
|
|
105
|
+
|
|
106
|
+
## Library use
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from mcpsweep import scan
|
|
110
|
+
|
|
111
|
+
for ep in scan(["10.10.0.31"], ports=[8090], proxy="http://127.0.0.1:8080"):
|
|
112
|
+
print(ep.url, ep.server_name, ep.risk_level)
|
|
113
|
+
for t in ep.tools:
|
|
114
|
+
print(" ", t.name, t.tags, "POISONED" if t.poisoned else "")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/mcpsweep/__init__.py
|
|
5
|
+
src/mcpsweep/__main__.py
|
|
6
|
+
src/mcpsweep/cli.py
|
|
7
|
+
src/mcpsweep/scanner.py
|
|
8
|
+
src/mcpsweep.egg-info/PKG-INFO
|
|
9
|
+
src/mcpsweep.egg-info/SOURCES.txt
|
|
10
|
+
src/mcpsweep.egg-info/dependency_links.txt
|
|
11
|
+
src/mcpsweep.egg-info/entry_points.txt
|
|
12
|
+
src/mcpsweep.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcpsweep
|