mcppt 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcppt/cli.py ADDED
@@ -0,0 +1,243 @@
1
+ """MCPPT CLI — entry point for `mcppt` command."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+
8
+ from .core import configure, mcp_init, rpc
9
+
10
+ CHECKS = [
11
+ "enum", "auth", "idor", "injection", "schema", "ssrf", "publish",
12
+ "rate", "stored", "scope", "replay", "context_overflow", "poison_all",
13
+ "tenant", "session", "rug_pull",
14
+ "headers", "error_disclosure", "tool_poisoning", "resources",
15
+ "cmd_injection", "path_traversal", "jwt_audit", "oauth_discovery",
16
+ "secret_scan", "tool_shadowing",
17
+ "sampling", "schema_leak",
18
+ ]
19
+
20
+ EPILOG = """
21
+ commands:
22
+ scan Run all/selected security checks with live TUI
23
+ list Enumerate tools and parameter schemas
24
+ call Call a single tool with custom JSON args
25
+ shell Launch interactive REPL (default when run with no args)
26
+ serve-mcp Expose MCPTROTTER itself as an MCP server
27
+
28
+ examples:
29
+ mcppt <- interactive shell
30
+ mcppt scan --url https://target.com/mcp --token eyJ...
31
+ mcppt scan --url https://target.com/mcp --token t1 --token2 t2 --checks idor,scope
32
+ mcppt scan --url https://target.com/mcp --no-verify --output report.md
33
+ mcppt scan --url https://target.com/mcp --proxy http://127.0.0.1:8080 --checks all
34
+ mcppt list --url https://target.com/mcp --token eyJ...
35
+ mcppt call --url https://target.com/mcp --token eyJ... --tool get_user --args '{"id":1}'
36
+ mcppt serve-mcp --port 8899
37
+ """
38
+
39
+
40
+ # ── scan ──────────────────────────────────────────────────────────────────────
41
+
42
+ def cmd_scan(args: argparse.Namespace) -> None:
43
+ from .checks import ScanState, run_scan, ALL_CHECKS
44
+ from .tui import run_tui
45
+ from .report import save_json, save_markdown
46
+
47
+ configure(no_verify=args.no_verify, proxy=args.proxy or None)
48
+
49
+ checks = [c.strip() for c in args.checks.split(",")]
50
+ run_all = "all" in checks
51
+ total = len(ALL_CHECKS) if run_all else len([c for c in checks if c in ALL_CHECKS])
52
+
53
+ state = ScanState(
54
+ url=args.url,
55
+ token=args.token or None,
56
+ token2=args.token2 or None,
57
+ checks_total=total,
58
+ )
59
+
60
+ run_tui(state, run_scan, state, checks)
61
+
62
+ if args.output:
63
+ p = (
64
+ save_markdown(state, args.output)
65
+ if args.output.endswith(".md")
66
+ else save_json(state, args.output)
67
+ )
68
+ print(f"\n Report saved → {p}")
69
+
70
+
71
+ # ── list ──────────────────────────────────────────────────────────────────────
72
+
73
+ def cmd_list(args: argparse.Namespace) -> None:
74
+ from rich import box
75
+ from rich.console import Console
76
+ from rich.table import Table
77
+
78
+ configure(no_verify=args.no_verify, proxy=args.proxy or None)
79
+ console = Console()
80
+
81
+ mcp_init(args.url, args.token or None)
82
+ r = rpc(args.url, "tools/list", {}, token=args.token or None)
83
+ if r["status"] != 200:
84
+ console.print(f"[red]tools/list failed: HTTP {r['status']}[/]")
85
+ sys.exit(1)
86
+
87
+ tools = r["body"].get("result", {}).get("tools", [])
88
+ if not tools:
89
+ console.print("[yellow]No tools returned.[/]")
90
+ return
91
+
92
+ console.print(f"\n[bold]{len(tools)} tools on [cyan]{args.url}[/][/]\n")
93
+ for t in tools:
94
+ name = t.get("name", "?")
95
+ desc = t.get("description", "").split("\n")[0][:80]
96
+ props = t.get("inputSchema", {}).get("properties", {})
97
+ required = t.get("inputSchema", {}).get("required", [])
98
+
99
+ console.print(f"[bold cyan]{name}[/] [dim]{desc}[/]")
100
+ if props:
101
+ tbl = Table(show_header=True, box=box.SIMPLE, header_style="bold dim", padding=(0, 1))
102
+ tbl.add_column("Field")
103
+ tbl.add_column("Type")
104
+ tbl.add_column("Req", width=4)
105
+ tbl.add_column("Description")
106
+ for field, meta in props.items():
107
+ req = "[red]*[/]" if field in required else ""
108
+ tbl.add_row(
109
+ field,
110
+ meta.get("type", "any"),
111
+ req,
112
+ meta.get("description", "")[:70],
113
+ )
114
+ console.print(tbl)
115
+ console.print()
116
+
117
+
118
+ # ── call ──────────────────────────────────────────────────────────────────────
119
+
120
+ def cmd_call(args: argparse.Namespace) -> None:
121
+ from rich.console import Console
122
+
123
+ configure(no_verify=args.no_verify, proxy=args.proxy or None)
124
+ console = Console()
125
+
126
+ try:
127
+ tool_args = json.loads(args.args)
128
+ except json.JSONDecodeError as e:
129
+ console.print(f"[red]Invalid JSON args: {e}[/]")
130
+ sys.exit(1)
131
+
132
+ mcp_init(args.url, args.token or None)
133
+ console.print(f"\nCalling [cyan]{args.tool}[/] on [cyan]{args.url}[/]...")
134
+ r = rpc(
135
+ args.url,
136
+ "tools/call",
137
+ {"name": args.tool, "arguments": tool_args},
138
+ token=args.token or None,
139
+ )
140
+ status_style = "green" if r["status"] == 200 else "red"
141
+ console.print(f"[{status_style}]HTTP {r['status']}[/]")
142
+
143
+ body = r["body"]
144
+ result = body.get("result", {})
145
+ content = result.get("content", [])
146
+ if content:
147
+ for item in content:
148
+ if item.get("type") == "text":
149
+ try:
150
+ console.print_json(json.dumps(json.loads(item["text"])))
151
+ except Exception:
152
+ console.print(item["text"])
153
+ elif "error" in body:
154
+ console.print(f"[red]Error:[/] {body['error']}")
155
+ else:
156
+ console.print_json(json.dumps(body))
157
+
158
+
159
+ # ── arg parsing ───────────────────────────────────────────────────────────────
160
+
161
+ def _add_common(p: argparse.ArgumentParser) -> None:
162
+ p.add_argument("--url", required=True, help="MCP server endpoint URL")
163
+ p.add_argument("--token", default=None, help="Bearer token (primary)")
164
+ p.add_argument("--no-verify", action="store_true", help="Skip SSL certificate verification")
165
+ p.add_argument("--proxy", default=None, help="Proxy URL e.g. http://127.0.0.1:8080")
166
+
167
+
168
+ def _ensure_utf8() -> None:
169
+ """Reconfigure stdout/stderr to UTF-8 on Windows so Rich box-drawing chars encode."""
170
+ import sys
171
+ if hasattr(sys.stdout, "reconfigure"):
172
+ try:
173
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
174
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
175
+ except Exception:
176
+ pass
177
+
178
+
179
+ def cmd_shell(_args=None) -> None:
180
+ from .shell import launch_shell
181
+ launch_shell()
182
+
183
+
184
+ def cmd_serve_mcp(args: argparse.Namespace) -> None:
185
+ from .server import serve
186
+ serve(port=args.port)
187
+
188
+
189
+ def main() -> None:
190
+ _ensure_utf8()
191
+ parser = argparse.ArgumentParser(
192
+ prog="mcppt",
193
+ description="MCPTROTTER v2.3 — MCP Pentest Tool | 28 automated security checks",
194
+ formatter_class=argparse.RawDescriptionHelpFormatter,
195
+ epilog=EPILOG,
196
+ )
197
+ sub = parser.add_subparsers(dest="command")
198
+
199
+ # scan
200
+ p_scan = sub.add_parser("scan", help="Run security scan (live TUI)")
201
+ _add_common(p_scan)
202
+ p_scan.add_argument("--token2", default=None, help="Second user token (IDOR/scope/tenant checks)")
203
+ p_scan.add_argument("--checks", default="all",
204
+ help=f"Comma-separated checks or 'all'. Options: {','.join(CHECKS)}")
205
+ p_scan.add_argument("--output", default=None,
206
+ help="Save report to file (report.json or report.md)")
207
+
208
+ # list
209
+ p_list = sub.add_parser("list", help="Enumerate tools and schemas")
210
+ _add_common(p_list)
211
+
212
+ # call
213
+ p_call = sub.add_parser("call", help="Call a single tool")
214
+ _add_common(p_call)
215
+ p_call.add_argument("--tool", required=True, help="Tool name to call")
216
+ p_call.add_argument("--args", default="{}", help="JSON arguments (default: {})")
217
+
218
+ # shell
219
+ sub.add_parser("shell", help="Launch interactive REPL (gobuster/ffuf-style)")
220
+
221
+ # serve-mcp
222
+ p_serve = sub.add_parser("serve-mcp", help="Expose MCPTROTTER as an MCP server")
223
+ p_serve.add_argument("--port", type=int, default=8899, help="Port to listen on (default: 8899)")
224
+
225
+ args = parser.parse_args()
226
+
227
+ # default: no subcommand → launch interactive shell
228
+ if not args.command:
229
+ cmd_shell()
230
+ return
231
+
232
+ dispatch = {
233
+ "scan": cmd_scan,
234
+ "list": cmd_list,
235
+ "call": cmd_call,
236
+ "shell": cmd_shell,
237
+ "serve-mcp": cmd_serve_mcp,
238
+ }
239
+ dispatch[args.command](args)
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()
mcppt/core.py ADDED
@@ -0,0 +1,169 @@
1
+ """Low-level JSON-RPC transport for MCP Streamable HTTP servers."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import ssl
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Any, Optional
10
+
11
+ _SESSION_ID: Optional[str] = None
12
+ _SSL_CTX: Optional[ssl.SSLContext] = None
13
+ _PROXY_HANDLER: Any = None
14
+
15
+
16
+ def configure(no_verify: bool = False, proxy: Optional[str] = None) -> None:
17
+ global _SSL_CTX, _PROXY_HANDLER
18
+ if no_verify:
19
+ ctx = ssl.create_default_context()
20
+ ctx.check_hostname = False
21
+ ctx.verify_mode = ssl.CERT_NONE
22
+ _SSL_CTX = ctx
23
+ else:
24
+ _SSL_CTX = None
25
+ _PROXY_HANDLER = (
26
+ urllib.request.ProxyHandler({"http": proxy, "https": proxy}) if proxy else None
27
+ )
28
+
29
+
30
+ def reset_session() -> None:
31
+ global _SESSION_ID
32
+ _SESSION_ID = None
33
+
34
+
35
+ def get_session_id() -> Optional[str]:
36
+ return _SESSION_ID
37
+
38
+
39
+ def set_session_id(sid: Optional[str]) -> None:
40
+ global _SESSION_ID
41
+ _SESSION_ID = sid
42
+
43
+
44
+ def rpc(
45
+ url: str,
46
+ method: str,
47
+ params: dict,
48
+ token: Optional[str] = None,
49
+ req_id: int = 1,
50
+ ) -> dict:
51
+ global _SESSION_ID
52
+ payload = json.dumps(
53
+ {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}
54
+ ).encode()
55
+ headers = {
56
+ "Content-Type": "application/json",
57
+ "Accept": "application/json, text/event-stream",
58
+ }
59
+ if token:
60
+ headers["Authorization"] = f"Bearer {token}"
61
+ if _SESSION_ID:
62
+ headers["mcp-session-id"] = _SESSION_ID
63
+
64
+ try:
65
+ req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
66
+ if _PROXY_HANDLER:
67
+ extra = (
68
+ urllib.request.HTTPSHandler(context=_SSL_CTX)
69
+ if _SSL_CTX
70
+ else urllib.request.HTTPSHandler()
71
+ )
72
+ opener = urllib.request.build_opener(_PROXY_HANDLER, extra)
73
+ elif _SSL_CTX:
74
+ opener = urllib.request.build_opener(
75
+ urllib.request.HTTPSHandler(context=_SSL_CTX)
76
+ )
77
+ else:
78
+ opener = urllib.request.build_opener()
79
+
80
+ with opener.open(req, timeout=15) as resp:
81
+ sid = resp.headers.get("mcp-session-id")
82
+ if sid and not _SESSION_ID:
83
+ _SESSION_ID = sid
84
+ return {
85
+ "status": resp.status,
86
+ "body": _parse_sse(resp.read().decode(errors="replace")),
87
+ }
88
+ except urllib.error.HTTPError as e:
89
+ try:
90
+ body = _parse_sse(e.read().decode(errors="replace"))
91
+ except Exception:
92
+ body = {"raw": str(e)}
93
+ return {"status": e.code, "body": body}
94
+ except Exception as e:
95
+ return {"status": 0, "body": {"error": str(e)}}
96
+
97
+
98
+ def _parse_sse(raw: str) -> dict:
99
+ raw = raw.strip()
100
+ if not raw:
101
+ return {"error": "Empty response"}
102
+ data_lines = [
103
+ line[5:].strip() for line in raw.splitlines() if line.startswith("data:")
104
+ ]
105
+ if data_lines:
106
+ try:
107
+ return json.loads("\n".join(data_lines))
108
+ except Exception:
109
+ return {"raw_sse": "\n".join(data_lines)}
110
+ try:
111
+ return json.loads(raw)
112
+ except Exception:
113
+ return {"raw": raw[:500]}
114
+
115
+
116
+ def mcp_init(url: str, token: Optional[str]) -> bool:
117
+ reset_session()
118
+ r = rpc(
119
+ url,
120
+ "initialize",
121
+ {
122
+ "protocolVersion": "2024-11-05",
123
+ "capabilities": {},
124
+ "clientInfo": {"name": "mcppt", "version": "2.0"},
125
+ },
126
+ token=token,
127
+ )
128
+ if r["status"] == 200 and "result" in r["body"]:
129
+ rpc(url, "notifications/initialized", {}, token=token, req_id=2)
130
+ return True
131
+ return False
132
+
133
+
134
+ def decode_jwt(token: str) -> dict:
135
+ try:
136
+ parts = token.split(".")
137
+ if len(parts) != 3:
138
+ return {}
139
+ padded = parts[1] + "=" * (4 - len(parts[1]) % 4)
140
+ return json.loads(
141
+ base64.urlsafe_b64decode(padded).decode("utf-8", errors="replace")
142
+ )
143
+ except Exception:
144
+ return {}
145
+
146
+
147
+ def jsonrpc_succeeded(body: dict) -> bool:
148
+ """True if the response indicates actual tool execution, not an auth rejection."""
149
+ if "result" not in body:
150
+ return False
151
+ content_text = "".join(
152
+ i.get("text", "").lower() for i in body["result"].get("content", [])
153
+ )
154
+ AUTH_KEYWORDS = [
155
+ "unauthorized", "forbidden", "401", "403", "access denied",
156
+ "not authenticated", "invalid token", "token expired",
157
+ ]
158
+ return not any(k in content_text for k in AUTH_KEYWORDS)
159
+
160
+
161
+ def is_auth_error(body: dict) -> bool:
162
+ err = body.get("error", {})
163
+ code = err.get("code", 0)
164
+ msg = str(err.get("message", "")).lower()
165
+ AUTH_KEYWORDS = [
166
+ "unauthorized", "forbidden", "authentication",
167
+ "not authenticated", "invalid token", "access denied",
168
+ ]
169
+ return code in (401, 403) or any(k in msg for k in AUTH_KEYWORDS)
mcppt/report.py ADDED
@@ -0,0 +1,105 @@
1
+ """Generate JSON and Markdown reports from a completed ScanState."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from .checks import ScanState
9
+
10
+ SEV_ICON = {"CRITICAL": "[CRIT]", "HIGH": "[HIGH]", "MEDIUM": "[MED]", "LOW": "[LOW]"}
11
+
12
+
13
+ def save_json(state: ScanState, path: str) -> Path:
14
+ out = Path(path)
15
+ data = {
16
+ "tool": "MCPPT",
17
+ "version": "2.0.0",
18
+ "timestamp": datetime.utcnow().isoformat() + "Z",
19
+ "target": state.url,
20
+ "elapsed_seconds": round(state.elapsed, 1),
21
+ "summary": {
22
+ sev: sum(1 for f in state.findings if f.severity == sev)
23
+ for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW")
24
+ },
25
+ "findings": [
26
+ {
27
+ "check": f.check,
28
+ "severity": f.severity,
29
+ "title": f.title,
30
+ "detail": f.detail,
31
+ }
32
+ for f in state.findings
33
+ ],
34
+ }
35
+ out.write_text(json.dumps(data, indent=2), encoding="utf-8")
36
+ return out
37
+
38
+
39
+ def save_markdown(state: ScanState, path: str) -> Path:
40
+ out = Path(path)
41
+ ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
42
+
43
+ lines = [
44
+ "# MCPPT Security Scan Report",
45
+ "",
46
+ f"**Target:** `{state.url}` ",
47
+ f"**Date:** {ts} ",
48
+ f"**Duration:** {state.elapsed:.1f}s ",
49
+ "",
50
+ "## Summary",
51
+ "",
52
+ "| Severity | Count |",
53
+ "| --- | --- |",
54
+ ]
55
+ for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
56
+ count = sum(1 for f in state.findings if f.severity == sev)
57
+ lines.append(f"| {SEV_ICON[sev]} {sev} | {count} |")
58
+
59
+ lines += ["", "## Findings", ""]
60
+
61
+ if not state.findings:
62
+ lines.append("_No findings detected — all checks passed._")
63
+ else:
64
+ for i, f in enumerate(state.findings, 1):
65
+ icon = SEV_ICON.get(f.severity, "⚪")
66
+ lines += [
67
+ f"### {i}. {icon} [{f.severity}] {f.title}",
68
+ "",
69
+ f"**Check:** `{f.check}` ",
70
+ f"**Severity:** {f.severity} ",
71
+ "",
72
+ f.detail,
73
+ "",
74
+ "---",
75
+ "",
76
+ ]
77
+
78
+ lines += [
79
+ "## Remediation Reference",
80
+ "",
81
+ "| Check | Risk | Fix |",
82
+ "| --- | --- | --- |",
83
+ "| `enum` | Info disclosure | Require auth on `tools/list` |",
84
+ "| `auth` | Auth bypass | Validate Bearer token server-side on every tool call |",
85
+ "| `idor` | Data exposure | Scope resource access to the authenticated user |",
86
+ "| `injection` | Prompt injection | Sanitise/escape all user-supplied strings before returning to LLM |",
87
+ "| `schema` | Input validation | Enforce strict type validation at the MCP server layer |",
88
+ "| `ssrf` | SSRF | Block RFC-1918/link-local URLs; use allowlist for external fetches |",
89
+ "| `publish` | Unconfirmed action | Enforce confirmation gate in MCP layer, not only in agent prompt |",
90
+ "| `rate` | DoS | Add rate limiting per token/IP |",
91
+ "| `stored` | Stored injection | Escape stored content before returning in tool responses |",
92
+ "| `scope` | Privilege escalation | Enforce token scopes server-side per tool |",
93
+ "| `replay` | Replay | Add per-request nonce or timestamp window validation |",
94
+ "| `context_overflow` | Context hijack | Enforce max field length at ingestion |",
95
+ "| `poison_all` | Injection | Sanitise every response field, not just primary content |",
96
+ "| `tenant` | Data leak | Scope cache keys and storage to tenant/user ID |",
97
+ "| `session` | Session hijack | Use CSPRNG ≥128-bit entropy for session IDs (UUID v4) |",
98
+ "| `rug_pull` | Supply chain | Pin tool versions; alert on description changes |",
99
+ "",
100
+ "---",
101
+ "_Generated by [MCPPT](https://github.com/gurudeepmallam-cmd/mcppt) v2.0_",
102
+ ]
103
+
104
+ out.write_text("\n".join(lines), encoding="utf-8")
105
+ return out