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 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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ mcpsweep = mcpsweep.cli:main
@@ -0,0 +1 @@
1
+ mcpsweep