pwndoc-mcp-server 1.0.8__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.
@@ -0,0 +1,635 @@
1
+ """
2
+ PwnDoc MCP Server - Command Line Interface.
3
+
4
+ Provides a rich CLI for managing the MCP server, configuration,
5
+ and PwnDoc interactions.
6
+
7
+ Usage:
8
+ pwndoc-mcp serve # Start MCP server (stdio)
9
+ pwndoc-mcp serve --transport sse # Start SSE server
10
+ pwndoc-mcp config init # Interactive configuration
11
+ pwndoc-mcp config show # Show current config
12
+ pwndoc-mcp test # Test connection
13
+ pwndoc-mcp version # Show version
14
+ """
15
+
16
+ import json
17
+ import logging
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Optional, Union
21
+
22
+ try:
23
+ import typer # type: ignore[import-not-found]
24
+ from rich.console import Console # type: ignore[import-not-found]
25
+ from rich.panel import Panel # type: ignore[import-not-found]
26
+ from rich.prompt import Prompt # type: ignore[import-not-found]
27
+ from rich.syntax import Syntax # type: ignore[import-not-found]
28
+ from rich.table import Table # type: ignore[import-not-found]
29
+
30
+ HAS_RICH = True
31
+ except ImportError:
32
+ HAS_RICH = False
33
+ typer = None # type: ignore[assignment]
34
+ Prompt = None # type: ignore[assignment,misc]
35
+
36
+ from pwndoc_mcp_server import __version__
37
+ from pwndoc_mcp_server.client import PwnDocClient, PwnDocError
38
+ from pwndoc_mcp_server.config import (
39
+ DEFAULT_CONFIG_FILE,
40
+ init_config_interactive,
41
+ load_config,
42
+ save_config,
43
+ )
44
+ from pwndoc_mcp_server.mcp_installer import (
45
+ get_all_mcp_servers,
46
+ get_claude_config_path,
47
+ install_mcp_config,
48
+ is_claude_installed,
49
+ uninstall_mcp_config,
50
+ )
51
+ from pwndoc_mcp_server.server import PwnDocMCPServer
52
+
53
+ # Create CLI app
54
+ if HAS_RICH:
55
+ app = typer.Typer(
56
+ name="pwndoc-mcp",
57
+ help="PwnDoc MCP Server - Model Context Protocol for Pentest Documentation",
58
+ add_completion=True,
59
+ )
60
+ console = Console()
61
+
62
+ def version_callback(value: bool):
63
+ """Callback for --version flag."""
64
+ if value:
65
+ console.print(f"pwndoc-mcp-server version {__version__}")
66
+ console.print("Author: Walid Faour <security@walidfaour.com>")
67
+ raise typer.Exit()
68
+
69
+ @app.callback()
70
+ def cli_callback(
71
+ version: Optional[bool] = typer.Option(
72
+ None,
73
+ "--version",
74
+ "-v",
75
+ help="Show version and exit",
76
+ callback=version_callback,
77
+ is_eager=True,
78
+ )
79
+ ):
80
+ """PwnDoc MCP Server CLI."""
81
+ pass
82
+
83
+ else:
84
+ app = None # type: ignore[assignment]
85
+ console = None # type: ignore[assignment]
86
+ version_callback = None # type: ignore[assignment]
87
+
88
+
89
+ def setup_logging(level: str = "INFO", log_file: Optional[str] = None):
90
+ """Configure logging."""
91
+ handlers: list[logging.Handler] = [logging.StreamHandler()]
92
+ if log_file:
93
+ handlers.append(logging.FileHandler(log_file))
94
+
95
+ logging.basicConfig(
96
+ level=getattr(logging, level.upper()),
97
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
98
+ handlers=handlers,
99
+ )
100
+
101
+
102
+ # =============================================================================
103
+ # VERSION COMMAND
104
+ # =============================================================================
105
+
106
+ if HAS_RICH:
107
+
108
+ @app.command()
109
+ def version():
110
+ """Show version information."""
111
+ console.print(
112
+ Panel(
113
+ f"[bold green]PwnDoc MCP Server[/bold green]\n"
114
+ f"Version: [cyan]{__version__}[/cyan]\n"
115
+ f"Author: [cyan]Walid Faour[/cyan]\n"
116
+ f"Email: [cyan]security@walidfaour.com[/cyan]\n"
117
+ f"Python: [cyan]{sys.version.split()[0]}[/cyan]",
118
+ title="Version Info",
119
+ )
120
+ )
121
+
122
+
123
+ # =============================================================================
124
+ # SERVE COMMAND
125
+ # =============================================================================
126
+
127
+ if HAS_RICH:
128
+
129
+ @app.command()
130
+ def serve(
131
+ transport: str = typer.Option("stdio", help="Transport type (stdio, sse)"),
132
+ host: str = typer.Option("127.0.0.1", help="Host for SSE/WebSocket"),
133
+ port: int = typer.Option(8080, help="Port for SSE/WebSocket"),
134
+ log_level: str = typer.Option("INFO", help="Log level"),
135
+ log_file: Optional[str] = typer.Option(None, help="Log file path"),
136
+ config_file: Optional[Path] = typer.Option(None, "--config", "-c", help="Config file path"),
137
+ url: Optional[str] = typer.Option(None, "--url", help="PwnDoc server URL"),
138
+ username: Optional[str] = typer.Option(None, "--username", "-u", help="PwnDoc username"),
139
+ password: Optional[str] = typer.Option(None, "--password", "-p", help="PwnDoc password"),
140
+ token: Optional[str] = typer.Option(None, "--token", "-t", help="PwnDoc JWT token"),
141
+ ):
142
+ """Start the MCP server."""
143
+ setup_logging(log_level, log_file)
144
+
145
+ try:
146
+ # Build config overrides from CLI arguments
147
+ overrides = {
148
+ "mcp_transport": transport,
149
+ "mcp_host": host,
150
+ "mcp_port": port,
151
+ "log_level": log_level,
152
+ "log_file": log_file or "",
153
+ }
154
+
155
+ # Add authentication overrides if provided
156
+ if url:
157
+ overrides["url"] = url
158
+ if username:
159
+ overrides["username"] = username
160
+ if password:
161
+ overrides["password"] = password
162
+ if token:
163
+ overrides["token"] = token
164
+
165
+ config = load_config(config_file=config_file, **overrides)
166
+
167
+ if not config.is_configured:
168
+ console.print(
169
+ "[red]Error:[/red] Server not configured. Run 'pwndoc-mcp config init' first."
170
+ )
171
+ raise typer.Exit(1)
172
+
173
+ if transport != "stdio":
174
+ console.print("[green]Starting PwnDoc MCP Server[/green]")
175
+ console.print(f" Transport: [cyan]{transport}[/cyan]")
176
+ console.print(f" URL: [cyan]{config.url}[/cyan]")
177
+ if transport == "sse":
178
+ console.print(f" Endpoint: [cyan]http://{host}:{port}/mcp[/cyan]")
179
+
180
+ server = PwnDocMCPServer(config)
181
+ server.run(transport)
182
+
183
+ except KeyboardInterrupt:
184
+ console.print("\n[yellow]Server stopped[/yellow]")
185
+ except Exception as e:
186
+ console.print(f"[red]Error:[/red] {e}")
187
+ raise typer.Exit(1)
188
+
189
+
190
+ # =============================================================================
191
+ # CONFIG COMMANDS
192
+ # =============================================================================
193
+
194
+ if HAS_RICH:
195
+ config_app = typer.Typer(help="Configuration management")
196
+ app.add_typer(config_app, name="config")
197
+
198
+ @config_app.command("init")
199
+ def config_init():
200
+ """Interactive configuration wizard."""
201
+ try:
202
+ init_config_interactive()
203
+ console.print("[green]✓ Configuration complete![/green]")
204
+ except KeyboardInterrupt:
205
+ console.print("\n[yellow]Configuration cancelled[/yellow]")
206
+
207
+ @config_app.command("show")
208
+ def config_show(
209
+ reveal_secrets: bool = typer.Option(False, "--reveal", help="Show sensitive values"),
210
+ ):
211
+ """Show current configuration."""
212
+ config = load_config()
213
+
214
+ table = Table(title="Current Configuration")
215
+ table.add_column("Setting", style="cyan")
216
+ table.add_column("Value", style="green")
217
+
218
+ table.add_row("URL", config.url or "[dim]not set[/dim]")
219
+ table.add_row("Username", config.username or "[dim]not set[/dim]")
220
+ table.add_row(
221
+ "Password",
222
+ (
223
+ ("*" * 8 if config.password else "[dim]not set[/dim]")
224
+ if not reveal_secrets
225
+ else config.password
226
+ ),
227
+ )
228
+ table.add_row(
229
+ "Token",
230
+ (
231
+ ("*" * 20 if config.token else "[dim]not set[/dim]")
232
+ if not reveal_secrets
233
+ else (config.token or "")
234
+ ),
235
+ )
236
+ table.add_row("Verify SSL", str(config.verify_ssl))
237
+ table.add_row("Timeout", str(config.timeout))
238
+ table.add_row("Log Level", config.log_level)
239
+ table.add_row("MCP Transport", config.mcp_transport)
240
+ table.add_row("Auth Method", config.auth_method)
241
+ table.add_row(
242
+ "Configured",
243
+ "[green]Yes[/green]" if config.is_configured else "[red]No[/red]",
244
+ )
245
+
246
+ console.print(table)
247
+ console.print(f"\nConfig file: [dim]{DEFAULT_CONFIG_FILE}[/dim]")
248
+
249
+ @config_app.command("set")
250
+ def config_set(
251
+ key: str = typer.Argument(..., help="Configuration key"),
252
+ value: str = typer.Argument(..., help="Configuration value"),
253
+ ):
254
+ """Set a configuration value."""
255
+ config = load_config()
256
+
257
+ if hasattr(config, key):
258
+ # Convert value to appropriate type
259
+ current = getattr(config, key)
260
+ converted_value: Union[bool, int, str]
261
+ if isinstance(current, bool):
262
+ converted_value = value.lower() in ("true", "1", "yes")
263
+ elif isinstance(current, int):
264
+ converted_value = int(value)
265
+ else:
266
+ converted_value = value
267
+
268
+ setattr(config, key, converted_value)
269
+ save_config(config)
270
+ console.print(f"[green]✓[/green] Set {key} = {value}")
271
+ else:
272
+ console.print(f"[red]Error:[/red] Unknown configuration key: {key}")
273
+ raise typer.Exit(1)
274
+
275
+ @config_app.command("path")
276
+ def config_path():
277
+ """Show configuration file path."""
278
+ console.print(f"[cyan]{DEFAULT_CONFIG_FILE}[/cyan]")
279
+ if DEFAULT_CONFIG_FILE.exists():
280
+ console.print("[green] (exists)[/green]")
281
+ else:
282
+ console.print("[yellow] (not created)[/yellow]")
283
+
284
+
285
+ # =============================================================================
286
+ # TEST COMMAND
287
+ # =============================================================================
288
+
289
+ if HAS_RICH:
290
+
291
+ @app.command()
292
+ def test(
293
+ config_file: Optional[Path] = typer.Option(None, "--config", "-c", help="Config file path"),
294
+ url: Optional[str] = typer.Option(None, "--url", help="PwnDoc server URL"),
295
+ username: Optional[str] = typer.Option(None, "--username", "-u", help="PwnDoc username"),
296
+ password: Optional[str] = typer.Option(None, "--password", "-p", help="PwnDoc password"),
297
+ token: Optional[str] = typer.Option(None, "--token", "-t", help="PwnDoc JWT token"),
298
+ ):
299
+ """Test connection to PwnDoc server."""
300
+ # Build config overrides from CLI arguments
301
+ overrides = {}
302
+ if url:
303
+ overrides["url"] = url
304
+ if username:
305
+ overrides["username"] = username
306
+ if password:
307
+ overrides["password"] = password
308
+ if token:
309
+ overrides["token"] = token
310
+
311
+ config = load_config(config_file=config_file, **overrides)
312
+
313
+ if not config.is_configured:
314
+ console.print(
315
+ "[red]Error:[/red] Server not configured. Run 'pwndoc-mcp config init' first."
316
+ )
317
+ raise typer.Exit(1)
318
+
319
+ console.print(f"Testing connection to [cyan]{config.url}[/cyan]...")
320
+
321
+ try:
322
+ with PwnDocClient(config) as client:
323
+ client.authenticate()
324
+ console.print("[green]✓ Authentication successful[/green]")
325
+
326
+ user = client.get_current_user()
327
+ console.print(f"[green]✓ Logged in as:[/green] {user.get('username', 'unknown')}")
328
+
329
+ audits = client.list_audits()
330
+ console.print(f"[green]✓ Found {len(audits)} audits[/green]")
331
+
332
+ console.print("\n[bold green]All tests passed![/bold green]")
333
+
334
+ except PwnDocError as e:
335
+ console.print(f"[red]✗ Connection failed:[/red] {e}")
336
+ raise typer.Exit(1)
337
+ except Exception as e:
338
+ console.print(f"[red]✗ Error:[/red] {e}")
339
+ raise typer.Exit(1)
340
+
341
+
342
+ # =============================================================================
343
+ # QUERY COMMAND (for quick queries)
344
+ # =============================================================================
345
+
346
+ if HAS_RICH:
347
+
348
+ @app.command()
349
+ def query(
350
+ tool: str = typer.Argument(..., help="Tool name to call"),
351
+ params: Optional[str] = typer.Option(None, "--params", "-p", help="JSON parameters"),
352
+ config_file: Optional[Path] = typer.Option(None, "--config", "-c", help="Config file path"),
353
+ ):
354
+ """Execute a tool query directly."""
355
+ config = load_config(config_file=config_file)
356
+
357
+ if not config.is_configured:
358
+ console.print("[red]Error:[/red] Server not configured.")
359
+ raise typer.Exit(1)
360
+
361
+ server = PwnDocMCPServer(config)
362
+
363
+ if tool not in server._tools:
364
+ console.print(f"[red]Error:[/red] Unknown tool: {tool}")
365
+ console.print("\nAvailable tools:")
366
+ for t in sorted(server._tools.keys()):
367
+ console.print(f" • {t}")
368
+ raise typer.Exit(1)
369
+
370
+ try:
371
+ arguments = json.loads(params) if params else {}
372
+ result = server._tools[tool].handler(**arguments)
373
+
374
+ # Pretty print result
375
+ syntax = Syntax(json.dumps(result, indent=2, default=str), "json", theme="monokai")
376
+ console.print(syntax)
377
+
378
+ except json.JSONDecodeError as e:
379
+ console.print(f"[red]Error:[/red] Invalid JSON parameters: {e}")
380
+ raise typer.Exit(1)
381
+ except Exception as e:
382
+ console.print(f"[red]Error:[/red] {e}")
383
+ raise typer.Exit(1)
384
+
385
+
386
+ # =============================================================================
387
+ # TOOLS COMMAND
388
+ # =============================================================================
389
+
390
+ if HAS_RICH:
391
+
392
+ @app.command()
393
+ def tools():
394
+ """List all available MCP tools."""
395
+ config = load_config()
396
+ server = PwnDocMCPServer(config)
397
+
398
+ table = Table(title="Available Tools")
399
+ table.add_column("Tool", style="cyan")
400
+ table.add_column("Description", style="white")
401
+
402
+ for name, tool in sorted(server._tools.items()):
403
+ table.add_row(
404
+ name,
405
+ tool.description[:60] + "..." if len(tool.description) > 60 else tool.description,
406
+ )
407
+
408
+ console.print(table)
409
+ console.print(f"\nTotal: [cyan]{len(server._tools)}[/cyan] tools")
410
+
411
+ # =============================================================================
412
+ # MCP CLAUDE INTEGRATION COMMANDS
413
+ # =============================================================================
414
+
415
+ @app.command()
416
+ def claude_install(
417
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing configuration"),
418
+ env_file: Optional[str] = typer.Option(
419
+ None, "--env-file", help="Path to .env file with credentials"
420
+ ),
421
+ skip_check: bool = typer.Option(
422
+ False, "--skip-check", help="Skip Claude Desktop installation check"
423
+ ),
424
+ ):
425
+ """Install PwnDoc MCP server for Claude Desktop."""
426
+ try:
427
+ console.print("\n[cyan]Installing PwnDoc MCP for Claude Desktop...[/cyan]\n")
428
+
429
+ # Check if Claude Desktop is installed
430
+ if not skip_check and not is_claude_installed():
431
+ console.print(
432
+ "[yellow]⚠ Claude Desktop does not appear to be installed.[/yellow]\n"
433
+ )
434
+ console.print(
435
+ "The configuration will be created, but Claude Desktop won't use it until installed.\n"
436
+ )
437
+ console.print(
438
+ "To use with other MCP clients, see: [cyan]pwndoc-mcp serve --help[/cyan]\n"
439
+ )
440
+
441
+ proceed = Prompt.ask("Continue anyway?", choices=["y", "n"], default="n")
442
+ if proceed.lower() != "y":
443
+ console.print("[yellow]Installation cancelled[/yellow]")
444
+ raise typer.Exit(0)
445
+
446
+ # Build environment variables from config if needed
447
+ env_vars = {}
448
+ config = load_config()
449
+ if config.url:
450
+ env_vars["PWNDOC_URL"] = config.url
451
+ if config.username:
452
+ env_vars["PWNDOC_USERNAME"] = config.username
453
+ if config.password:
454
+ env_vars["PWNDOC_PASSWORD"] = config.password
455
+ if config.token:
456
+ env_vars["PWNDOC_TOKEN"] = config.token
457
+
458
+ # Install configuration
459
+ success = install_mcp_config(env=env_vars if env_vars else None, force=force)
460
+
461
+ if success:
462
+ config_path = get_claude_config_path()
463
+ console.print(
464
+ Panel(
465
+ f"[green]✓[/green] PwnDoc MCP installed successfully!\n\n"
466
+ f"Config file: [cyan]{config_path}[/cyan]\n\n"
467
+ f"[yellow]Next steps:[/yellow]\n"
468
+ f"1. Restart Claude Desktop\n"
469
+ f"2. PwnDoc tools will be available in Claude\n\n"
470
+ f"[dim]For other MCP clients: pwndoc-mcp serve[/dim]",
471
+ title="Installation Complete",
472
+ border_style="green",
473
+ )
474
+ )
475
+ else:
476
+ console.print("[yellow]Already installed (use --force to overwrite)[/yellow]")
477
+
478
+ except Exception as e:
479
+ console.print(f"[red]✗ Installation failed: {e}[/red]")
480
+ raise typer.Exit(1)
481
+
482
+ @app.command()
483
+ def claude_uninstall():
484
+ """Remove PwnDoc MCP server from Claude Desktop."""
485
+ try:
486
+ console.print("\n[cyan]Uninstalling PwnDoc MCP from Claude Desktop...[/cyan]\n")
487
+
488
+ success = uninstall_mcp_config()
489
+
490
+ if success:
491
+ console.print(
492
+ Panel(
493
+ "[green]✓[/green] PwnDoc MCP uninstalled successfully!\n\n"
494
+ "[yellow]Restart Claude Desktop to apply changes.[/yellow]",
495
+ title="Uninstallation Complete",
496
+ border_style="green",
497
+ )
498
+ )
499
+ else:
500
+ console.print("[yellow]Not installed[/yellow]")
501
+
502
+ except Exception as e:
503
+ console.print(f"[red]✗ Uninstallation failed: {e}[/red]")
504
+ raise typer.Exit(1)
505
+
506
+ @app.command()
507
+ def claude_status():
508
+ """Show Claude Desktop MCP configuration status."""
509
+ try:
510
+ config_path = get_claude_config_path()
511
+ console.print(f"\nClaude config path: [cyan]{config_path}[/cyan]")
512
+
513
+ if not config_path.exists():
514
+ console.print("[yellow]Claude configuration file not found[/yellow]")
515
+ if is_claude_installed():
516
+ console.print("[green]✓[/green] Claude Desktop appears to be installed")
517
+ console.print("Config file will be created on first MCP installation")
518
+ else:
519
+ console.print("[yellow]Have you installed Claude Desktop?[/yellow]")
520
+ return
521
+
522
+ # Get all MCP servers
523
+ all_servers = get_all_mcp_servers()
524
+
525
+ if not all_servers:
526
+ console.print("\n[yellow]No MCP servers configured[/yellow]")
527
+ console.print("\nRun [cyan]pwndoc-mcp claude-install[/cyan] to install")
528
+ return
529
+
530
+ # Check if pwndoc-mcp is configured
531
+ mcp_config = all_servers.get("pwndoc-mcp")
532
+
533
+ console.print(f"\n[cyan]Found {len(all_servers)} MCP server(s) configured:[/cyan]\n")
534
+
535
+ if mcp_config:
536
+ console.print("[green]✓[/green] PwnDoc MCP is installed\n")
537
+
538
+ # Display pwndoc-mcp configuration
539
+ syntax = Syntax(
540
+ json.dumps({"pwndoc-mcp": mcp_config}, indent=2), "json", theme="monokai"
541
+ )
542
+ console.print(syntax)
543
+
544
+ # Show other servers if any
545
+ other_servers = {k: v for k, v in all_servers.items() if k != "pwndoc-mcp"}
546
+ if other_servers:
547
+ console.print(
548
+ f"\n[dim]Other MCP servers: {', '.join(other_servers.keys())}[/dim]"
549
+ )
550
+ else:
551
+ console.print("[yellow]PwnDoc MCP is not installed[/yellow]\n")
552
+
553
+ # Show what IS configured
554
+ console.print("[cyan]Configured MCP servers:[/cyan]")
555
+ for server_name in all_servers.keys():
556
+ console.print(f" • {server_name}")
557
+
558
+ console.print("\nRun [cyan]pwndoc-mcp claude-install[/cyan] to install PwnDoc MCP")
559
+
560
+ except Exception as e:
561
+ console.print(f"[red]Error: {e}[/red]")
562
+ raise typer.Exit(1)
563
+
564
+
565
+ # =============================================================================
566
+ # MAIN ENTRY POINT
567
+ # =============================================================================
568
+
569
+
570
+ def main():
571
+ """Main entry point for CLI."""
572
+ if not HAS_RICH:
573
+ # Fallback to simple argparse if rich/typer not available
574
+ import argparse
575
+
576
+ parser = argparse.ArgumentParser(
577
+ description="PwnDoc MCP Server",
578
+ formatter_class=argparse.RawDescriptionHelpFormatter,
579
+ epilog="""
580
+ Examples:
581
+ pwndoc-mcp serve Start MCP server (stdio)
582
+ pwndoc-mcp serve --transport sse Start SSE server
583
+
584
+ For rich CLI experience, install: pip install typer[all] rich
585
+ """,
586
+ )
587
+
588
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
589
+
590
+ # Serve command
591
+ serve_parser = subparsers.add_parser("serve", help="Start MCP server")
592
+ serve_parser.add_argument("--transport", default="stdio", choices=["stdio", "sse"])
593
+ serve_parser.add_argument("--host", default="127.0.0.1")
594
+ serve_parser.add_argument("--port", type=int, default=8080)
595
+ serve_parser.add_argument("--log-level", default="INFO")
596
+ serve_parser.add_argument("--config", "-c", type=Path)
597
+
598
+ # Version command
599
+ subparsers.add_parser("version", help="Show version")
600
+
601
+ # Test command
602
+ test_parser = subparsers.add_parser("test", help="Test connection")
603
+ test_parser.add_argument("--config", "-c", type=Path)
604
+
605
+ args = parser.parse_args()
606
+
607
+ if args.command == "version":
608
+ print(f"PwnDoc MCP Server v{__version__}")
609
+ elif args.command == "serve":
610
+ setup_logging(args.log_level)
611
+ config = load_config(
612
+ config_file=args.config,
613
+ mcp_transport=args.transport,
614
+ mcp_host=args.host,
615
+ mcp_port=args.port,
616
+ )
617
+ server = PwnDocMCPServer(config)
618
+ server.run()
619
+ elif args.command == "test":
620
+ config = load_config(config_file=args.config)
621
+ try:
622
+ with PwnDocClient(config) as client:
623
+ client.authenticate()
624
+ print("✓ Connection successful!")
625
+ except Exception as e:
626
+ print(f"✗ Connection failed: {e}")
627
+ sys.exit(1)
628
+ else:
629
+ parser.print_help()
630
+ else:
631
+ app()
632
+
633
+
634
+ if __name__ == "__main__":
635
+ main()