mcpnuke 6.22.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.
Files changed (132) hide show
  1. mcpnuke/__init__.py +14 -0
  2. mcpnuke/__main__.py +272 -0
  3. mcpnuke/_docsgen.py +297 -0
  4. mcpnuke/_runner_entry.py +55 -0
  5. mcpnuke/checks/__init__.py +859 -0
  6. mcpnuke/checks/_lane_helpers.py +65 -0
  7. mcpnuke/checks/actuator_probe.py +229 -0
  8. mcpnuke/checks/agentic_loop.py +176 -0
  9. mcpnuke/checks/ai_guardrail_probe.py +143 -0
  10. mcpnuke/checks/anon_budget_exhaust.py +182 -0
  11. mcpnuke/checks/base.py +102 -0
  12. mcpnuke/checks/behavioral.py +446 -0
  13. mcpnuke/checks/chaining.py +345 -0
  14. mcpnuke/checks/command_injection_broad.py +213 -0
  15. mcpnuke/checks/config_dump.py +85 -0
  16. mcpnuke/checks/config_tampering.py +79 -0
  17. mcpnuke/checks/credential_in_schema.py +40 -0
  18. mcpnuke/checks/dpop_enforcement.py +263 -0
  19. mcpnuke/checks/error_leakage.py +120 -0
  20. mcpnuke/checks/execution.py +90 -0
  21. mcpnuke/checks/exfil_flow.py +225 -0
  22. mcpnuke/checks/inference_backend.py +757 -0
  23. mcpnuke/checks/injection.py +297 -0
  24. mcpnuke/checks/input_sanitization.py +290 -0
  25. mcpnuke/checks/insecure_agent_comms.py +97 -0
  26. mcpnuke/checks/jwt_boundary.py +245 -0
  27. mcpnuke/checks/jwt_validation.py +220 -0
  28. mcpnuke/checks/k8s_chain_probe.py +69 -0
  29. mcpnuke/checks/list_cache.py +135 -0
  30. mcpnuke/checks/llm_analysis.py +865 -0
  31. mcpnuke/checks/model_routing.py +111 -0
  32. mcpnuke/checks/permissions.py +109 -0
  33. mcpnuke/checks/prompt_injection_t01.py +188 -0
  34. mcpnuke/checks/prompt_leakage.py +33 -0
  35. mcpnuke/checks/protected_resource_metadata.py +229 -0
  36. mcpnuke/checks/rate_limit.py +89 -0
  37. mcpnuke/checks/remote_package_exec.py +140 -0
  38. mcpnuke/checks/resource_poisoning.py +121 -0
  39. mcpnuke/checks/response_credentials.py +80 -0
  40. mcpnuke/checks/routing_header_binding.py +100 -0
  41. mcpnuke/checks/schema_overdisclosure.py +147 -0
  42. mcpnuke/checks/scope_pollution.py +164 -0
  43. mcpnuke/checks/sdk_cache_tamper.py +338 -0
  44. mcpnuke/checks/shadow_mcp_server.py +57 -0
  45. mcpnuke/checks/shell_injection.py +193 -0
  46. mcpnuke/checks/ssrf_probe.py +136 -0
  47. mcpnuke/checks/supply_chain.py +33 -0
  48. mcpnuke/checks/taxonomy_coverage.py +729 -0
  49. mcpnuke/checks/teleport.py +258 -0
  50. mcpnuke/checks/teleport_labs.py +295 -0
  51. mcpnuke/checks/temporal_consistency.py +88 -0
  52. mcpnuke/checks/theft.py +56 -0
  53. mcpnuke/checks/tool_output_poisoning.py +161 -0
  54. mcpnuke/checks/tool_probes.py +468 -0
  55. mcpnuke/checks/tool_response_injection.py +161 -0
  56. mcpnuke/checks/transport.py +77 -0
  57. mcpnuke/checks/webhook_persistence.py +78 -0
  58. mcpnuke/cli.py +895 -0
  59. mcpnuke/cli_steps.py +711 -0
  60. mcpnuke/core/__init__.py +13 -0
  61. mcpnuke/core/auth.py +517 -0
  62. mcpnuke/core/cache_hints.py +19 -0
  63. mcpnuke/core/chain_args.py +243 -0
  64. mcpnuke/core/chain_replay.py +733 -0
  65. mcpnuke/core/chain_templates.py +106 -0
  66. mcpnuke/core/constants.py +105 -0
  67. mcpnuke/core/enumerator.py +317 -0
  68. mcpnuke/core/llm.py +547 -0
  69. mcpnuke/core/llm_ollama.py +552 -0
  70. mcpnuke/core/llm_prompts.py +344 -0
  71. mcpnuke/core/models.py +140 -0
  72. mcpnuke/core/oast.py +266 -0
  73. mcpnuke/core/protocol.py +74 -0
  74. mcpnuke/core/session.py +379 -0
  75. mcpnuke/core/taxonomy.py +140 -0
  76. mcpnuke/core/transports/__init__.py +5 -0
  77. mcpnuke/core/transports/base.py +78 -0
  78. mcpnuke/core/transports/http.py +196 -0
  79. mcpnuke/core/transports/sse.py +192 -0
  80. mcpnuke/core/transports/stdio.py +145 -0
  81. mcpnuke/core/transports/toolserver.py +285 -0
  82. mcpnuke/core/trust.py +68 -0
  83. mcpnuke/data/public_targets.txt +33 -0
  84. mcpnuke/data/taxonomy/lanes.yaml +618 -0
  85. mcpnuke/data/tool_names.txt +105 -0
  86. mcpnuke/diff.py +254 -0
  87. mcpnuke/k8s/Dockerfile +22 -0
  88. mcpnuke/k8s/__init__.py +10 -0
  89. mcpnuke/k8s/discovery.py +348 -0
  90. mcpnuke/k8s/fingerprint.py +221 -0
  91. mcpnuke/k8s/manifests/cronjob.yaml +58 -0
  92. mcpnuke/k8s/manifests/job.yaml +55 -0
  93. mcpnuke/k8s/manifests/kustomization.yaml +17 -0
  94. mcpnuke/k8s/manifests/namespace.yaml +7 -0
  95. mcpnuke/k8s/manifests/rbac-impersonate.yaml +30 -0
  96. mcpnuke/k8s/manifests/rbac.yaml +40 -0
  97. mcpnuke/k8s/manifests/serviceaccount.yaml +9 -0
  98. mcpnuke/k8s/scanner.py +592 -0
  99. mcpnuke/patterns/__init__.py +25 -0
  100. mcpnuke/patterns/credentials.py +293 -0
  101. mcpnuke/patterns/probes.py +398 -0
  102. mcpnuke/patterns/rules.py +181 -0
  103. mcpnuke/patterns/tokens.py +32 -0
  104. mcpnuke/policy/__init__.py +6 -0
  105. mcpnuke/policy/generator.py +259 -0
  106. mcpnuke/policy/nullfield.py +120 -0
  107. mcpnuke/policy/rules.py +146 -0
  108. mcpnuke/profile.py +123 -0
  109. mcpnuke/py.typed +0 -0
  110. mcpnuke/reporting/__init__.py +35 -0
  111. mcpnuke/reporting/by_lane.py +224 -0
  112. mcpnuke/reporting/console.py +188 -0
  113. mcpnuke/reporting/coverage_report.py +231 -0
  114. mcpnuke/reporting/diff.py +124 -0
  115. mcpnuke/reporting/files.py +22 -0
  116. mcpnuke/reporting/json_out.py +178 -0
  117. mcpnuke/reporting/lab_coverage.py +89 -0
  118. mcpnuke/reporting/owasp.py +229 -0
  119. mcpnuke/reporting/priority.py +410 -0
  120. mcpnuke/reporting/sarif.py +168 -0
  121. mcpnuke/reporting/taxonomy_coverage.py +179 -0
  122. mcpnuke/scanner.py +358 -0
  123. mcpnuke/server/__init__.py +16 -0
  124. mcpnuke/server/app.py +109 -0
  125. mcpnuke/server/models.py +96 -0
  126. mcpnuke/server/runner.py +207 -0
  127. mcpnuke/subcommands.py +205 -0
  128. mcpnuke-6.22.0.dist-info/METADATA +372 -0
  129. mcpnuke-6.22.0.dist-info/RECORD +132 -0
  130. mcpnuke-6.22.0.dist-info/WHEEL +4 -0
  131. mcpnuke-6.22.0.dist-info/entry_points.txt +3 -0
  132. mcpnuke-6.22.0.dist-info/licenses/LICENSE +21 -0
mcpnuke/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """
2
+ mcpnuke — MCP Red Teaming & Security Scanner
3
+
4
+ Security auditing for Model Context Protocol servers. Enumerates tools,
5
+ probes for injection and poisoning, detects rug pulls, and audits
6
+ Kubernetes MCP deployments.
7
+
8
+ Usage:
9
+ mcpnuke --targets http://localhost:9090
10
+ mcpnuke --port-range localhost:9001-9010 --verbose
11
+ python -m mcpnuke --targets http://localhost:9090
12
+ """
13
+
14
+ __version__ = "6.22.0"
mcpnuke/__main__.py ADDED
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ mcpnuke — MCP Red Teaming & Security Scanner
4
+
5
+ Usage:
6
+ mcpnuke --targets http://localhost:9090
7
+ mcpnuke --port-range localhost:9001-9010 --verbose
8
+ mcpnuke --targets http://target:9090 --auth-token $TOKEN --json report.json
9
+ mcpnuke --stdio 'npx -y @modelcontextprotocol/server-everything'
10
+ mcpnuke --targets http://target:9090 --fast --group-findings
11
+ """
12
+
13
+ import sys
14
+ import time
15
+ from datetime import datetime
16
+
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+
20
+ from mcpnuke import __version__
21
+ from mcpnuke.cli import build_url_list, parse_args
22
+ from mcpnuke.cli_steps import (
23
+ _apply_baseline,
24
+ _base_probe_opts,
25
+ _k8s_setup,
26
+ _print_mode_notices,
27
+ _print_scan_panel,
28
+ _report,
29
+ _resolve_auth,
30
+ _should_fail,
31
+ _start_oast,
32
+ _validate_ai_backend,
33
+ _watch_lanes,
34
+ )
35
+ from mcpnuke.core.auth import parse_header_kv_pairs
36
+ from mcpnuke.core.models import TargetResult
37
+ from mcpnuke.core.trust import parse_trust_sets
38
+ from mcpnuke.diff import (
39
+ load_baseline,
40
+ save_baseline,
41
+ )
42
+ from mcpnuke.patterns.credentials import redact_credentials
43
+ from mcpnuke.scanner import run_parallel, scan_stdio_target, scan_target
44
+ from mcpnuke.subcommands import (
45
+ EXIT_CLEAN,
46
+ EXIT_ERROR,
47
+ EXIT_FINDINGS,
48
+ _run_diff_subcommand,
49
+ _run_doctor,
50
+ _run_lab_coverage,
51
+ )
52
+
53
+
54
+ def _main_inner() -> None:
55
+ # Handle `mcpnuke diff a.json b.json` before full arg parse
56
+ if len(sys.argv) >= 2 and sys.argv[1] == "diff":
57
+ _run_diff_subcommand(sys.argv[2:])
58
+ return
59
+
60
+ args = parse_args()
61
+ console = Console(no_color=args.no_color, force_terminal=not args.no_color)
62
+ from mcpnuke.core.llm import configure_bedrock
63
+ configure_bedrock(enabled=False)
64
+ try:
65
+ extra_headers = parse_header_kv_pairs(getattr(args, "header", None))
66
+ except ValueError as e:
67
+ console.print(f"[red]Error: {e}[/red]")
68
+ sys.exit(EXIT_ERROR)
69
+ if args.dpop_proof:
70
+ extra_headers["DPoP"] = args.dpop_proof
71
+
72
+ if args.doctor:
73
+ _run_doctor(console)
74
+ sys.exit(EXIT_CLEAN)
75
+
76
+ if getattr(args, "lab_coverage", None):
77
+ _run_lab_coverage(args.lab_coverage, console, getattr(args, "json_out", None))
78
+ sys.exit(EXIT_CLEAN)
79
+
80
+ _validate_ai_backend(args)
81
+
82
+ deterministic_mode: bool = bool(getattr(args, "deterministic", False))
83
+ effective_probe_workers: int = args.probe_workers
84
+ effective_phase2_workers: int = args.claude_phase2_workers
85
+ if deterministic_mode:
86
+ effective_probe_workers = 1
87
+ effective_phase2_workers = 1
88
+
89
+ # --stdio mode: scan a local server via stdin/stdout, then exit
90
+ if args.stdio:
91
+ probe_opts = _base_probe_opts(
92
+ args,
93
+ extra_headers=extra_headers,
94
+ probe_workers=effective_probe_workers,
95
+ phase2_workers=effective_phase2_workers,
96
+ deterministic=deterministic_mode,
97
+ )
98
+
99
+ panel_lines = [
100
+ f"[bold cyan]mcpnuke v{__version__}[/bold cyan] [dim]MCP Red Teaming & Security Scanner[/dim]",
101
+ "Mode : stdio",
102
+ f"Command : {args.stdio}",
103
+ f"Fast : {args.fast}",
104
+ f"Workers : {effective_probe_workers} probe thread(s)",
105
+ f"Started : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
106
+ ]
107
+ if deterministic_mode:
108
+ panel_lines.append("Deterministic: True")
109
+ console.print(Panel("\n".join(panel_lines), title="mcpnuke", border_style="cyan"))
110
+
111
+ result = scan_stdio_target(
112
+ args.stdio,
113
+ timeout=args.timeout,
114
+ verbose=args.verbose,
115
+ probe_opts=probe_opts,
116
+ )
117
+ _report(args, [result], console)
118
+ if _should_fail(result.findings, getattr(args, "fail_on", "high")):
119
+ sys.exit(EXIT_FINDINGS)
120
+ sys.exit(EXIT_CLEAN)
121
+
122
+ inference_only = (
123
+ getattr(args, "inference_host", None)
124
+ and not args.targets
125
+ and not args.targets_file
126
+ and not getattr(args, "public_targets", False)
127
+ and not args.port_range
128
+ )
129
+ if inference_only or (
130
+ args.k8s_discover
131
+ and not args.targets
132
+ and not args.targets_file
133
+ and not getattr(args, "public_targets", False)
134
+ and not args.port_range
135
+ ):
136
+ urls = []
137
+ else:
138
+ urls = build_url_list(args)
139
+
140
+ try:
141
+ trust_sets = parse_trust_sets(getattr(args, "trust_set", None), targets=urls)
142
+ except ValueError as exc:
143
+ console.print(f" [red]✗[/red] {exc}")
144
+ sys.exit(EXIT_ERROR)
145
+
146
+ auth_token, auth_context_summary = _resolve_auth(args, urls, extra_headers, console)
147
+
148
+ baseline = {}
149
+ if args.baseline:
150
+ baseline = load_baseline(args.baseline)
151
+ if not baseline:
152
+ console.print(f"[yellow]Baseline empty or not found: {args.baseline}[/yellow]")
153
+
154
+ _print_scan_panel(
155
+ args,
156
+ urls,
157
+ auth_token=auth_token,
158
+ auth_context_summary=auth_context_summary,
159
+ extra_headers=extra_headers,
160
+ probe_workers=effective_probe_workers,
161
+ phase2_workers=effective_phase2_workers,
162
+ deterministic=deterministic_mode,
163
+ console=console,
164
+ )
165
+
166
+ profile_data = None
167
+ if getattr(args, "profile", None):
168
+ from mcpnuke.profile import load_profile
169
+ try:
170
+ profile_data = load_profile(args.profile)
171
+ console.print(f" [green]Profile loaded:[/green] {profile_data.name} ({len(profile_data.tools)} tools)")
172
+ except (FileNotFoundError, ValueError) as exc:
173
+ console.print(f" [red]Profile error:[/red] {exc}")
174
+
175
+ probe_opts = {
176
+ **_base_probe_opts(
177
+ args,
178
+ extra_headers=extra_headers,
179
+ probe_workers=effective_probe_workers,
180
+ phase2_workers=effective_phase2_workers,
181
+ deterministic=deterministic_mode,
182
+ ),
183
+ "trust_sets": [sorted(group) for group in trust_sets],
184
+ "target_count": len(urls),
185
+ "auth_context_summary": auth_context_summary,
186
+ "profile": profile_data,
187
+ }
188
+
189
+ oast_listener = _start_oast(args, console)
190
+ if oast_listener is not None:
191
+ probe_opts["oast"] = oast_listener
192
+
193
+ _print_mode_notices(args, deterministic_mode, console)
194
+
195
+ _k8s_setup(args, urls, console)
196
+
197
+ if not urls and inference_only:
198
+ from mcpnuke.checks.inference_backend import check_inference_backend, check_model_integrity
199
+ t0 = time.time()
200
+ result = TargetResult(url=probe_opts["inference_host"])
201
+ result.transport = "inference-only"
202
+ check_inference_backend(result, probe_opts=probe_opts)
203
+ if probe_opts.get("inference_baseline") or probe_opts.get("save_inference_baseline"):
204
+ check_model_integrity(result, probe_opts=probe_opts)
205
+ result.timings["total"] = time.time() - t0
206
+ results = [result]
207
+ _report(args, results, console)
208
+ if probe_opts.get("save_inference_baseline"):
209
+ console.print(f"\n [green]✓[/green] Inference baseline saved to {probe_opts['save_inference_baseline']}")
210
+ from mcpnuke.k8s.scanner import GLOBAL_K8S_FINDINGS
211
+ if _should_fail(result.findings + GLOBAL_K8S_FINDINGS, getattr(args, "fail_on", "high")):
212
+ sys.exit(EXIT_FINDINGS)
213
+ sys.exit(EXIT_CLEAN)
214
+
215
+ if not urls:
216
+ from mcpnuke.k8s.scanner import GLOBAL_K8S_FINDINGS
217
+ if GLOBAL_K8S_FINDINGS:
218
+ console.print(f"\n[bold]── K8s-Only Report ({len(GLOBAL_K8S_FINDINGS)} findings) ──[/bold]")
219
+ _report(args, [], console)
220
+ if _should_fail(GLOBAL_K8S_FINDINGS, getattr(args, "fail_on", "high")):
221
+ sys.exit(EXIT_FINDINGS)
222
+ sys.exit(EXIT_CLEAN)
223
+ console.print("[red]No targets specified and K8s discovery found nothing.[/red]")
224
+ sys.exit(EXIT_ERROR)
225
+
226
+ if len(urls) == 1:
227
+ results = [
228
+ scan_target(
229
+ urls[0],
230
+ [],
231
+ timeout=args.timeout,
232
+ verbose=args.verbose,
233
+ auth_token=auth_token,
234
+ probe_opts=probe_opts,
235
+ )
236
+ ]
237
+ else:
238
+ results = run_parallel(
239
+ urls,
240
+ timeout=args.timeout,
241
+ workers=args.workers,
242
+ verbose=args.verbose,
243
+ auth_token=auth_token,
244
+ probe_opts=probe_opts,
245
+ )
246
+
247
+ _apply_baseline(args, results, baseline, console)
248
+
249
+ _report(args, results, console)
250
+ if args.save_baseline:
251
+ save_baseline(results, args.save_baseline, console=console)
252
+
253
+ if getattr(args, "watch", None):
254
+ _watch_lanes(args, results, urls, auth_token, probe_opts, baseline, console)
255
+ return
256
+
257
+ from mcpnuke.k8s.scanner import GLOBAL_K8S_FINDINGS
258
+ all_findings = [f for r in results for f in r.findings] + GLOBAL_K8S_FINDINGS
259
+ if _should_fail(all_findings, getattr(args, "fail_on", "high")):
260
+ sys.exit(EXIT_FINDINGS)
261
+
262
+
263
+ def main() -> None:
264
+ try:
265
+ _main_inner()
266
+ except Exception as exc:
267
+ print(f"mcpnuke: {type(exc).__name__}: {redact_credentials(str(exc))}", file=sys.stderr)
268
+ sys.exit(EXIT_ERROR)
269
+
270
+
271
+ if __name__ == "__main__":
272
+ main()
mcpnuke/_docsgen.py ADDED
@@ -0,0 +1,297 @@
1
+ """Paths to the reference documents, and the renderer for the generated ones.
2
+
3
+ Two kinds of document are named here and only one of them is generated.
4
+
5
+ The CLI reference is derived from the argparse parser rather than maintained by
6
+ hand, because the hand-maintained copy drifted 14 flags behind before anyone
7
+ noticed. tests/test_docs_current.py fails the build if the committed output
8
+ stops matching the parser.
9
+
10
+ docs/checks.md is the other kind: hand-written, because a check's severity and
11
+ what it detects cannot be derived from its source. Its path lives here so that
12
+ the test guarding it can reference one place where doc paths are declared,
13
+ which is the only thing the two have in common.
14
+
15
+ DANGER: main() must never write CHECKS_PATH. Adding it there for symmetry with
16
+ CLI_REFERENCE_PATH would overwrite a document nothing can regenerate, and would
17
+ do it silently, on a command whose whole contract is "safe to re-run".
18
+ test_generator_never_writes_the_hand_written_doc fails if main() so much as
19
+ names it.
20
+
21
+ Regenerate: uv run python -m mcpnuke._docsgen
22
+
23
+ Never import this module from runtime code. REPO_ROOT walks up from __file__
24
+ to find a checkout, which is meaningless in an installed wheel — the package
25
+ ships whole, so this file is present but its paths point nowhere useful. It is
26
+ a build-time tool that happens to live inside the package so it can import the
27
+ parsers it documents.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import ast
34
+ from pathlib import Path
35
+ from typing import TypeGuard
36
+
37
+ from mcpnuke.cli import build_parser
38
+ from mcpnuke.subcommands import _build_diff_parser
39
+
40
+ REPO_ROOT: Path = Path(__file__).resolve().parent.parent
41
+ CLI_REFERENCE_PATH: Path = REPO_ROOT / "docs" / "cli-reference.md"
42
+
43
+ # Hand-written, unlike the CLI reference: severity and detection prose cannot be
44
+ # derived from source. Named here so the completeness test that guards it does
45
+ # not have to rebuild a repo-root path of its own. Read-only to this module —
46
+ # see the warning in the module docstring before using it anywhere that writes.
47
+ CHECKS_PATH: Path = REPO_ROOT / "docs" / "checks.md"
48
+
49
+ # Read as text, not imported: the association between an env var and the flag
50
+ # it backs exists only in the source, since argparse keeps the resolved value
51
+ # and throws the variable name away.
52
+ CLI_SOURCE_PATH: Path = Path(__file__).resolve().parent / "cli.py"
53
+
54
+ BANNER: str = (
55
+ "<!-- GENERATED FILE — do not edit by hand.\n"
56
+ " Regenerate: uv run python -m mcpnuke._docsgen\n"
57
+ " Source: mcpnuke/cli.py -->"
58
+ )
59
+
60
+ # argparse's own groups. "options" holds only -h/--help after the flags were
61
+ # split into named groups, so rendering it would emit a stub section.
62
+ _SKIP_TITLES: frozenset[str] = frozenset({"positional arguments", "options"})
63
+
64
+ ENV_HEADING: str = "Environment Variables"
65
+ DIFF_HEADING: str = "Subcommand: `mcpnuke diff`"
66
+
67
+ # The only prose in the generated document. Everything factual — every option,
68
+ # positional and variable name — comes from a parser or from the source.
69
+ _DIFF_PROSE: str = (
70
+ "Compares two saved scan reports. `diff` is dispatched off `sys.argv` "
71
+ "before the main parser runs, so it takes its own arguments and none of "
72
+ "the options above."
73
+ )
74
+ _DIFF_EXIT_NOTE: str = (
75
+ "Exits 1 when the newer report contains findings the baseline did not, "
76
+ "so it can gate a CI job."
77
+ )
78
+ _ENV_PROSE: str = (
79
+ "Each variable supplies the default for one flag; passing the flag wins. "
80
+ "Only names are listed here — a value is a credential."
81
+ )
82
+
83
+
84
+ def _is_environ_get(node: ast.AST) -> TypeGuard[ast.Call]:
85
+ """True for an `os.environ.get(...)` call node."""
86
+ return (
87
+ isinstance(node, ast.Call)
88
+ and isinstance(node.func, ast.Attribute)
89
+ and node.func.attr == "get"
90
+ and getattr(node.func.value, "attr", "") == "environ"
91
+ and bool(node.args)
92
+ )
93
+
94
+
95
+ def _module_string_constants(tree: ast.Module) -> dict[str, str]:
96
+ """Module-level `NAME = "literal"` bindings, for resolving indirection.
97
+
98
+ `--auth-token` reads `os.environ.get(AUTH_TOKEN_ENV)`. A literal-only walk
99
+ finds 13 of the 14 variables and drops MCP_AUTH_TOKEN, the one most worth
100
+ documenting.
101
+ """
102
+ return {
103
+ target.id: node.value.value
104
+ for node in tree.body
105
+ if isinstance(node, ast.Assign)
106
+ and isinstance(node.value, ast.Constant)
107
+ and isinstance(node.value.value, str)
108
+ for target in node.targets
109
+ if isinstance(target, ast.Name)
110
+ }
111
+
112
+
113
+ def _environ_name(node: ast.AST, constants: dict[str, str]) -> str | None:
114
+ """The variable name an `os.environ.get(...)` call reads, if it is static."""
115
+ if not _is_environ_get(node):
116
+ return None
117
+ key = node.args[0]
118
+ if isinstance(key, ast.Constant) and isinstance(key.value, str):
119
+ return key.value
120
+ if isinstance(key, ast.Name):
121
+ return constants.get(key.id)
122
+ return None
123
+
124
+
125
+ def _parse_cli_source() -> tuple[ast.Module, dict[str, str]]:
126
+ tree = ast.parse(CLI_SOURCE_PATH.read_text())
127
+ return tree, _module_string_constants(tree)
128
+
129
+
130
+ def count_environ_reads() -> int:
131
+ """How many `os.environ.get(...)` calls cli.py makes with a static name.
132
+
133
+ Exists so a test can assert the flag association below loses none of them;
134
+ a silent drop would put a variable back in the undocumented pile.
135
+ """
136
+ tree, constants = _parse_cli_source()
137
+ return len({
138
+ name
139
+ for node in ast.walk(tree)
140
+ if (name := _environ_name(node, constants)) is not None
141
+ })
142
+
143
+
144
+ def env_var_flag_pairs() -> list[tuple[str, str]]:
145
+ """Every env var cli.py reads, paired with the flag whose default it sets.
146
+
147
+ Walks `add_argument` calls and takes the first string positional as the
148
+ flag, so the pairing comes from the source rather than a list someone has
149
+ to remember to update.
150
+ """
151
+ tree, constants = _parse_cli_source()
152
+ pairs: set[tuple[str, str]] = set()
153
+ for node in ast.walk(tree):
154
+ if not (
155
+ isinstance(node, ast.Call)
156
+ and isinstance(node.func, ast.Attribute)
157
+ and node.func.attr == "add_argument"
158
+ ):
159
+ continue
160
+ flag = next(
161
+ (
162
+ arg.value
163
+ for arg in node.args
164
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str)
165
+ ),
166
+ None,
167
+ )
168
+ if flag is None:
169
+ continue
170
+ for inner in ast.walk(node):
171
+ name = _environ_name(inner, constants)
172
+ if name is not None:
173
+ pairs.add((name, flag))
174
+ return sorted(pairs)
175
+
176
+
177
+ def _help_text(action: argparse.Action) -> str:
178
+ """The help string as `--help` shows it, without expanding any values.
179
+
180
+ argparse %-formats help strings before printing, so a literal percent is
181
+ written `%%` in the source. Undoing that escape here keeps the doc matching
182
+ `--help`. Note this is *not* argparse's `_expand_help`: that would also
183
+ substitute `%(default)s`, and fourteen defaults come from `os.environ.get`,
184
+ several of them secrets. Nothing here ever reads a default.
185
+ """
186
+ return (action.help or "").replace("%%", "%")
187
+
188
+
189
+ def _cell(text: str) -> str:
190
+ """Collapse whitespace and escape pipes so a table row survives markdown."""
191
+ return " ".join(text.split()).replace("|", "\\|")
192
+
193
+
194
+ def _row(formatter: argparse.HelpFormatter, action: argparse.Action) -> str:
195
+ """One table row: the invocation exactly as --help prints it, plus help."""
196
+ invocation = formatter._format_action_invocation(action)
197
+ return f"| `{_cell(invocation)}` | {_cell(_help_text(action))} |"
198
+
199
+
200
+ def _table(header: str) -> list[str]:
201
+ return [f"| {header} | Description |", "|---|---|"]
202
+
203
+
204
+ def _render_flag_groups(parser: argparse.ArgumentParser) -> list[str]:
205
+ """One section per curated argument group, in declaration order."""
206
+ formatter = parser._get_formatter()
207
+ lines: list[str] = []
208
+ for group in parser._action_groups:
209
+ if group.title in _SKIP_TITLES:
210
+ continue
211
+ actions = [
212
+ a
213
+ for a in group._group_actions
214
+ if a.help != argparse.SUPPRESS and a.option_strings
215
+ ]
216
+ if not actions:
217
+ continue
218
+ lines += [f"## {group.title}", "", *_table("Option")]
219
+ lines += [_row(formatter, a) for a in actions]
220
+ lines.append("")
221
+ return lines
222
+
223
+
224
+ def _render_env_vars() -> list[str]:
225
+ return [
226
+ f"## {ENV_HEADING}",
227
+ "",
228
+ _ENV_PROSE,
229
+ "",
230
+ "| Variable | Flag |",
231
+ "|---|---|",
232
+ *(f"| `{var}` | `{flag}` |" for var, flag in env_var_flag_pairs()),
233
+ "",
234
+ ]
235
+
236
+
237
+ def _render_diff_subcommand(parser: argparse.ArgumentParser) -> list[str]:
238
+ """The `diff` section, tables generated, prose hand-written.
239
+
240
+ Rendered separately because `diff` has positionals, which the main
241
+ renderer filters out rather than loosening its own filter for one section.
242
+ """
243
+ formatter = parser._get_formatter()
244
+ actions = [
245
+ a
246
+ for a in parser._actions
247
+ if a.help != argparse.SUPPRESS and "--help" not in a.option_strings
248
+ ]
249
+ return [
250
+ f"## {DIFF_HEADING}",
251
+ "",
252
+ _DIFF_PROSE,
253
+ "",
254
+ "```bash",
255
+ "mcpnuke diff OLD.json NEW.json",
256
+ "```",
257
+ "",
258
+ *_table("Argument"),
259
+ *(_row(formatter, a) for a in actions),
260
+ "",
261
+ _DIFF_EXIT_NOTE,
262
+ ]
263
+
264
+
265
+ def render_cli_reference(
266
+ parser: argparse.ArgumentParser,
267
+ diff_parser: argparse.ArgumentParser | None = None,
268
+ ) -> str:
269
+ """Render the full CLI reference as markdown.
270
+
271
+ Only invocations and help text are emitted. Fourteen flags default to
272
+ `os.environ.get(...)`, several of them secrets, so a defaults column would
273
+ bake the generating machine's credentials into a committed file.
274
+ """
275
+ lines: list[str] = [
276
+ BANNER,
277
+ "",
278
+ "# CLI Reference",
279
+ "",
280
+ "Every option `mcpnuke` accepts, grouped as `--help` groups them.",
281
+ "Generated from the parser, so it cannot fall behind the code.",
282
+ "",
283
+ *_render_flag_groups(parser),
284
+ *_render_env_vars(),
285
+ *_render_diff_subcommand(diff_parser or _build_diff_parser()),
286
+ ]
287
+ return "\n".join(lines).rstrip() + "\n"
288
+
289
+
290
+ def main() -> None:
291
+ CLI_REFERENCE_PATH.parent.mkdir(parents=True, exist_ok=True)
292
+ CLI_REFERENCE_PATH.write_text(render_cli_reference(build_parser()))
293
+ print(f"wrote {CLI_REFERENCE_PATH.relative_to(REPO_ROOT)}")
294
+
295
+
296
+ if __name__ == "__main__":
297
+ main()
@@ -0,0 +1,55 @@
1
+ """Console-script shim for ``mcpnuke-runner``.
2
+
3
+ The runner needs the optional ``server`` extra (fastapi, uvicorn, pydantic),
4
+ but its console script is installed by the base package. Pointing the script
5
+ straight at ``mcpnuke.server.app:run`` meant a plain ``pip install mcpnuke``
6
+ followed by ``mcpnuke-runner`` printed a ModuleNotFoundError traceback.
7
+
8
+ This module sits deliberately *outside* ``mcpnuke.server``: that package's
9
+ ``__init__`` imports pydantic through ``models``, so any handler written
10
+ inside it would need the very dependency it is trying to report on.
11
+
12
+ Nothing here may import the server package at module level.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from collections.abc import Callable
19
+
20
+ # The distribution names that make up the `server` extra. A missing import
21
+ # outside this set is a real bug in the server package, and is re-raised
22
+ # rather than reported as a missing extra.
23
+ _SERVER_EXTRA_MODULES: frozenset[str] = frozenset({"fastapi", "uvicorn", "pydantic"})
24
+
25
+ _EXIT_MISSING_EXTRA: int = 2
26
+
27
+
28
+ def _load_run() -> Callable[[], None]:
29
+ """Import the real entry point. Kept separate so tests can replace it."""
30
+ from mcpnuke.server.app import run
31
+
32
+ return run
33
+
34
+
35
+ def main() -> None:
36
+ try:
37
+ run = _load_run()
38
+ except ImportError as exc:
39
+ missing = (getattr(exc, "name", "") or "").split(".")[0]
40
+ if missing not in _SERVER_EXTRA_MODULES:
41
+ raise
42
+ sys.stderr.write(
43
+ f"mcpnuke-runner needs the 'server' extra, and {missing} is not installed.\n"
44
+ "\n"
45
+ " uv tool install 'mcpnuke[server]'\n"
46
+ " pip install 'mcpnuke[server]'\n"
47
+ "\n"
48
+ "The scanner itself does not need it — plain 'mcpnuke' works without.\n"
49
+ )
50
+ raise SystemExit(_EXIT_MISSING_EXTRA) from None
51
+ run()
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()