mcp-debugger 0.1.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.
mcp_debugger/cli.py ADDED
@@ -0,0 +1,2185 @@
1
+ """CLI entry point for mcp-debugger."""
2
+
3
+ import asyncio
4
+ from datetime import datetime, timezone
5
+ import io
6
+ import json
7
+ import os
8
+ import sqlite3
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any, List, Optional, Tuple
12
+
13
+ import aiosqlite
14
+ import typer
15
+ from rich.console import Console, Group
16
+ from rich.panel import Panel
17
+ from rich.syntax import Syntax
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+
21
+ from mcp_debugger.proxy.stdio_proxy import StdioProxy
22
+ from mcp_debugger.storage.database import Database
23
+ from mcp_debugger.protocol.error_classifier import ErrorClassifier
24
+ from mcp_debugger.analytics import (
25
+ aggregate_session_stats,
26
+ compare_sessions_stats,
27
+ generate_sparkline,
28
+ generate_bar_chart,
29
+ )
30
+
31
+ _os_name = os.name
32
+
33
+ app = typer.Typer(help="MCP proxy debugger – inspect, record, validate, and replay MCP sessions")
34
+ console = Console()
35
+
36
+ # Check terminal encoding capabilities for safe emoji / symbol printing on legacy consoles (e.g. Windows cp1252)
37
+ _encoding = console.encoding or "utf-8"
38
+
39
+ def safe_char(char: str, fallback: str) -> str:
40
+ try:
41
+ char.encode(_encoding)
42
+ return char
43
+ except Exception:
44
+ return fallback
45
+
46
+ EMOJI_SHINE = safe_char("✨", "")
47
+ EMOJI_SEARCH = safe_char("🔍", "")
48
+ EMOJI_WRENCH = safe_char("🔧", "")
49
+ EMOJI_INFO = safe_char("💡", "")
50
+ EMOJI_CHART = safe_char("📊", "")
51
+ EMOJI_CHECK = safe_char("✓", "OK")
52
+ EMOJI_CROSS = safe_char("✗", "FAIL")
53
+ EMOJI_TIMEOUT = safe_char("⏱", "TIMEOUT")
54
+ EMOJI_ERROR = safe_char("❌", "ERROR")
55
+
56
+
57
+ @app.callback()
58
+ def callback() -> None:
59
+ """MCP proxy debugger."""
60
+
61
+
62
+ @app.command()
63
+ def version() -> None:
64
+ """Show version and exit."""
65
+ title = f"{EMOJI_SHINE} MCP Debugger".strip()
66
+
67
+ console.print(
68
+ Panel(
69
+ "mcp-debugger v0.1.0",
70
+ title=title,
71
+ border_style="green",
72
+ safe_box=True,
73
+ )
74
+ )
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Config command group
79
+ # ---------------------------------------------------------------------------
80
+
81
+ config_app = typer.Typer(help="Manage mcp-debugger configuration.")
82
+ app.add_typer(config_app, name="config")
83
+
84
+
85
+ @config_app.command(name="init")
86
+ def config_init(
87
+ force: bool = typer.Option(
88
+ False, "--force", help="Overwrite existing config without prompting"
89
+ ),
90
+ ) -> None:
91
+ """Create the default config file (~/.mcp-debugger/config.toml)."""
92
+ from mcp_debugger.config import Config, default_config_path
93
+
94
+ path = default_config_path()
95
+ if path.exists() and not force:
96
+ overwrite = typer.confirm(
97
+ f"Config file already exists at {path}. Overwrite?", default=False
98
+ )
99
+ if not overwrite:
100
+ console.print("[yellow]Aborted.[/yellow]")
101
+ raise typer.Exit(0)
102
+ cfg = Config(path=path)
103
+ cfg.reset()
104
+ console.print(f"[green]✓ Config file created at {path}[/green]")
105
+
106
+
107
+ @config_app.command(name="get")
108
+ def config_get(
109
+ key: str = typer.Argument(..., help="Config key in dot-notation, e.g. replay.timeout"),
110
+ ) -> None:
111
+ """Show the value of a config key."""
112
+ from mcp_debugger.config import get_config
113
+
114
+ cfg = get_config()
115
+ value = cfg.get(key)
116
+ if value is None:
117
+ console.print(f"[yellow]Key '{key}' not found.[/yellow]")
118
+ raise typer.Exit(1)
119
+ console.print(f"{key} = {value!r}")
120
+
121
+
122
+ @config_app.command(name="set")
123
+ def config_set(
124
+ key: str = typer.Argument(..., help="Config key in dot-notation, e.g. replay.timeout"),
125
+ value: str = typer.Argument(
126
+ ..., help="Value to store (auto-converted to int/bool/float if possible)"
127
+ ),
128
+ ) -> None:
129
+ """Set a config value and save to disk."""
130
+ import mcp_debugger.config as _cfg_mod
131
+
132
+ cfg = _cfg_mod.get_config()
133
+ cfg.set(key, value)
134
+ # Invalidate singleton so next read reflects the new value
135
+ _cfg_mod._GLOBAL_CONFIG = None
136
+ new_val = cfg.get(key)
137
+ console.print(f"[green]✓[/green] {key} = {new_val!r}")
138
+
139
+
140
+ @config_app.command(name="unset")
141
+ def config_unset(
142
+ key: str = typer.Argument(..., help="Config key to remove (reverts to default)"),
143
+ ) -> None:
144
+ """Remove a config key (reverts to the hardcoded default)."""
145
+ import mcp_debugger.config as _cfg_mod
146
+
147
+ cfg = _cfg_mod.get_config()
148
+ removed = cfg.unset(key)
149
+ _cfg_mod._GLOBAL_CONFIG = None # invalidate
150
+ if removed:
151
+ console.print(f"[green]✓[/green] '{key}' removed from config.")
152
+ else:
153
+ console.print(f"[yellow]Key '{key}' was not found in config.[/yellow]")
154
+
155
+
156
+ @config_app.command(name="list")
157
+ def config_list() -> None:
158
+ """Show all config values in a formatted table."""
159
+ from mcp_debugger.config import get_config, default_config_path
160
+
161
+ cfg = get_config()
162
+ data = cfg.all()
163
+
164
+ table = Table(title="mcp-debugger configuration", show_header=True, header_style="bold cyan")
165
+ table.add_column("Section", style="bold")
166
+ table.add_column("Key")
167
+ table.add_column("Value", style="green")
168
+
169
+ for section, section_val in data.items():
170
+ if not isinstance(section_val, dict):
171
+ continue
172
+ first = True
173
+ for k, v in section_val.items():
174
+ if isinstance(v, dict):
175
+ # nested (profiles sub-tables)
176
+ for sub_k, sub_v in v.items():
177
+ table.add_row(
178
+ section if first else "",
179
+ f"{k}.{sub_k}",
180
+ repr(sub_v),
181
+ )
182
+ first = False
183
+ else:
184
+ table.add_row(section if first else "", k, repr(v))
185
+ first = False
186
+
187
+ console.print(table)
188
+ console.print(f"\n[dim]Config file: {default_config_path()}[/dim]")
189
+
190
+
191
+ @config_app.command(name="reset")
192
+ def config_reset(
193
+ force: bool = typer.Option(False, "--force", help="Reset without prompting"),
194
+ ) -> None:
195
+ """Reset config to factory defaults."""
196
+ import mcp_debugger.config as _cfg_mod
197
+
198
+ if not force:
199
+ confirm = typer.confirm(
200
+ "Reset config to defaults? This will overwrite your current config.", default=False
201
+ )
202
+ if not confirm:
203
+ console.print("[yellow]Aborted.[/yellow]")
204
+ raise typer.Exit(0)
205
+ cfg = _cfg_mod.Config()
206
+ cfg.reset()
207
+ _cfg_mod._GLOBAL_CONFIG = None # invalidate singleton
208
+ console.print("[green]✓ Config reset to defaults.[/green]")
209
+
210
+
211
+ def convert_utc_to_local_string(utc_str: str) -> str:
212
+ """Convert a UTC time string from SQLite into a local timezone formatted string."""
213
+ utc_str_clean = utc_str.replace("T", " ")
214
+ try:
215
+ dt_utc = datetime.strptime(utc_str_clean, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
216
+ dt_local = dt_utc.astimezone()
217
+ return dt_local.strftime("%Y-%m-%d %H:%M:%S")
218
+ except Exception:
219
+ return utc_str
220
+
221
+
222
+ def format_duration(seconds: int, status: str) -> str:
223
+ """Format duration in seconds to a human readable format (e.g. '2m 3s')."""
224
+ if seconds < 0:
225
+ seconds = 0
226
+
227
+ if seconds < 60:
228
+ duration_str = f"{seconds}s"
229
+ else:
230
+ minutes = seconds // 60
231
+ secs = seconds % 60
232
+ if minutes < 60:
233
+ duration_str = f"{minutes}m {secs}s"
234
+ else:
235
+ hours = minutes // 60
236
+ mins = minutes % 60
237
+ duration_str = f"{hours}h {mins}m {secs}s"
238
+
239
+ if status == "running":
240
+ return f"{duration_str} (running)"
241
+ return duration_str
242
+
243
+
244
+ def truncate_command(cmd: str, max_len: int = 60) -> str:
245
+ """Truncate command string with ellipsis if it exceeds max_len."""
246
+ if len(cmd) <= max_len:
247
+ return cmd
248
+ return cmd[: max_len - 3] + "..."
249
+
250
+
251
+ def get_status_text(status: str) -> Text:
252
+ """Create a formatted Text status with color dot."""
253
+ if status == "completed":
254
+ return Text.assemble(("●", "green"), " completed")
255
+ elif status == "running":
256
+ return Text.assemble(("●", "yellow"), " running")
257
+ else:
258
+ return Text.assemble(("●", "red"), f" {status}")
259
+
260
+
261
+ @app.command(name="proxy")
262
+ def proxy(
263
+ server: str = typer.Option(
264
+ ..., "--server", "-s", help="The command to launch the target MCP server"
265
+ ),
266
+ name: Optional[str] = typer.Option(
267
+ None, "--name", "-n", help="A friendly name/label for the debugging session"
268
+ ),
269
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose debug logging"),
270
+ ) -> None:
271
+ """Launch the transparent stdio proxy and log session traffic to SQLite."""
272
+
273
+ async def _run() -> None:
274
+ db = Database()
275
+ try:
276
+ await db.connect()
277
+
278
+ # Create a new session
279
+ session_id = await db.create_session(server_command=server, friendly_name=name)
280
+ if session_id == -1:
281
+ print("[mcp-debugger error] Failed to create database session.", file=sys.stderr)
282
+ sys.exit(1)
283
+
284
+ proxy_engine = StdioProxy(
285
+ server_command=server,
286
+ database=db,
287
+ session_id=session_id,
288
+ verbose=verbose,
289
+ )
290
+
291
+ # Run the proxy loop
292
+ exit_code = await proxy_engine.run()
293
+ sys.exit(exit_code)
294
+ finally:
295
+ await db.close()
296
+
297
+ try:
298
+ asyncio.run(_run())
299
+ except KeyboardInterrupt:
300
+ sys.exit(0)
301
+
302
+
303
+ @app.command(name="list")
304
+ def list_sessions(
305
+ limit: int = typer.Option(20, "--limit", "-l", help="Maximum number of sessions to display"),
306
+ status: Optional[str] = typer.Option(
307
+ None,
308
+ "--status",
309
+ help="Filter sessions by status (running, completed, error)",
310
+ ),
311
+ json_mode: bool = typer.Option(
312
+ False,
313
+ "--json",
314
+ help="Output raw JSON array of session objects for scripting",
315
+ ),
316
+ ) -> None:
317
+ """List historical debugging sessions."""
318
+
319
+ async def _run() -> None:
320
+ db = Database()
321
+ try:
322
+ try:
323
+ await db.connect()
324
+ sessions = await db.get_sessions(limit=limit, status_filter=status)
325
+ except (sqlite3.DatabaseError, aiosqlite.DatabaseError):
326
+ console.print(
327
+ f"[red]Error: Database file at {db.db_path} appears to be corrupted or invalid.[/red]"
328
+ )
329
+ console.print(
330
+ "[yellow]Recovery Suggestion: Try deleting or renaming the file to reset the database.[/yellow]"
331
+ )
332
+ sys.exit(1)
333
+ except Exception as e:
334
+ console.print(f"[red]Error listing sessions: {e}[/red]")
335
+ sys.exit(1)
336
+
337
+ if not sessions:
338
+ if json_mode:
339
+ print("[]")
340
+ else:
341
+ console.print(
342
+ "[yellow]No sessions found. Run mcp-debugger proxy first.[/yellow]"
343
+ )
344
+ return
345
+
346
+ if json_mode:
347
+ json_sessions = []
348
+ for s in sessions:
349
+ json_sessions.append(
350
+ {
351
+ "id": s["id"],
352
+ "name": s["friendly_name"],
353
+ "server_command": s["server_command"],
354
+ "started_at": s["started_at"].replace(" ", "T") + "Z"
355
+ if s["started_at"]
356
+ else None,
357
+ "ended_at": s["ended_at"].replace(" ", "T") + "Z"
358
+ if s["ended_at"]
359
+ else None,
360
+ "status": s["status"],
361
+ "message_count": s["total_messages"],
362
+ "duration_seconds": s["duration_seconds"],
363
+ }
364
+ )
365
+ print(json.dumps(json_sessions, indent=2))
366
+ else:
367
+ table = Table(title="MCP Debugger Sessions", border_style="blue")
368
+ table.add_column("ID", justify="right", style="cyan")
369
+ table.add_column("Name", style="magenta")
370
+ table.add_column("Server Command", style="white")
371
+ table.add_column("Started At (Local)", style="white")
372
+ table.add_column("Duration", style="cyan")
373
+ table.add_column("Messages", justify="right", style="green")
374
+ table.add_column("Status", justify="center")
375
+
376
+ for s in sessions:
377
+ table.add_row(
378
+ str(s["id"]),
379
+ s["friendly_name"] or "—",
380
+ truncate_command(s["server_command"]),
381
+ convert_utc_to_local_string(s["started_at"]),
382
+ format_duration(s["duration_seconds"], s["status"]),
383
+ str(s["total_messages"]),
384
+ get_status_text(s["status"]),
385
+ )
386
+ console.print(table)
387
+ finally:
388
+ await db.close()
389
+
390
+ try:
391
+ asyncio.run(_run())
392
+ except KeyboardInterrupt:
393
+ sys.exit(0)
394
+
395
+
396
+ @app.command(name="inspect")
397
+ def inspect(
398
+ session_id: int = typer.Argument(..., help="The ID of the session to inspect"),
399
+ method: Optional[str] = typer.Option(
400
+ None,
401
+ "--method",
402
+ help="Filter messages by method name (case-sensitive)",
403
+ ),
404
+ direction: Optional[str] = typer.Option(
405
+ None,
406
+ "--direction",
407
+ help="Filter messages by direction (client_to_server, server_to_client)",
408
+ ),
409
+ search: Optional[str] = typer.Option(
410
+ None,
411
+ "--search",
412
+ help="Substring search in the JSON body (raw text)",
413
+ ),
414
+ limit: Optional[int] = typer.Option(
415
+ None,
416
+ "--limit",
417
+ help="Maximum number of messages to show",
418
+ ),
419
+ offset: Optional[int] = typer.Option(
420
+ None,
421
+ "--offset",
422
+ help="Skip the first N messages",
423
+ ),
424
+ json_mode: bool = typer.Option(
425
+ False,
426
+ "--json",
427
+ help="Output raw JSON instead of Rich terminal format",
428
+ ),
429
+ output: Optional[str] = typer.Option(
430
+ None,
431
+ "--output",
432
+ "-o",
433
+ help="Write output to a file instead of stdout",
434
+ ),
435
+ ) -> None:
436
+ """Inspect and format captured messages from a specific session."""
437
+
438
+ def rebuild_jsonrpc(row: dict[str, Any]) -> dict[str, Any]:
439
+ msg: dict[str, Any] = {"jsonrpc": "2.0"}
440
+ if row.get("message_id") is not None:
441
+ raw_id = row["message_id"]
442
+ try:
443
+ msg["id"] = int(raw_id)
444
+ except ValueError:
445
+ msg["id"] = raw_id
446
+
447
+ if row.get("method") is not None and row.get("message_type") in ("request", "notification"):
448
+ msg["method"] = row["method"]
449
+
450
+ for field in ("params", "result", "error"):
451
+ val = row.get(field)
452
+ if val is not None:
453
+ try:
454
+ msg[field] = json.loads(val)
455
+ except Exception:
456
+ msg[field] = val
457
+ return msg
458
+
459
+ async def _run() -> None:
460
+ db = Database()
461
+ try:
462
+ try:
463
+ await db.connect()
464
+ session = await db.get_session(session_id)
465
+ except (sqlite3.DatabaseError, aiosqlite.DatabaseError):
466
+ console.print(
467
+ f"[red]Error: Database file at {db.db_path} appears to be corrupted or invalid.[/red]"
468
+ )
469
+ console.print(
470
+ "[yellow]Recovery Suggestion: Try deleting or renaming the file to reset the database.[/yellow]"
471
+ )
472
+ sys.exit(1)
473
+ except Exception as e:
474
+ console.print(f"[red]Error connecting to database: {e}[/red]")
475
+ sys.exit(1)
476
+
477
+ if not session:
478
+ console.print(f"Session {session_id} not found")
479
+ sys.exit(1)
480
+
481
+ try:
482
+ messages = await db.get_messages(
483
+ session_id=session_id,
484
+ method=method,
485
+ direction=direction,
486
+ search=search,
487
+ limit=limit,
488
+ offset=offset,
489
+ )
490
+ try:
491
+ errors = await db.get_errors(session_id)
492
+ error_map = {
493
+ err["message_id"]: err
494
+ for err in errors
495
+ if err.get("message_id") is not None
496
+ }
497
+ except Exception:
498
+ error_map = {}
499
+ except Exception as e:
500
+ console.print(f"[red]Error fetching messages: {e}[/red]")
501
+ sys.exit(1)
502
+
503
+ if not messages:
504
+ if json_mode:
505
+ output_str = "[]"
506
+ if output:
507
+ with open(output, "w", encoding="utf-8") as f:
508
+ f.write(output_str + "\n")
509
+ else:
510
+ print(output_str)
511
+ else:
512
+ if output:
513
+ with open(output, "w", encoding="utf-8") as f:
514
+ f.write("No messages\n")
515
+ else:
516
+ console.print("No messages")
517
+ return
518
+
519
+ if json_mode:
520
+ json_messages = []
521
+ for msg in messages:
522
+ params_val = None
523
+ if msg.get("params") is not None:
524
+ try:
525
+ params_val = json.loads(msg["params"])
526
+ except Exception:
527
+ params_val = msg["params"]
528
+
529
+ result_val = None
530
+ if msg.get("result") is not None:
531
+ try:
532
+ result_val = json.loads(msg["result"])
533
+ except Exception:
534
+ result_val = msg["result"]
535
+
536
+ error_val = None
537
+ if msg.get("error") is not None:
538
+ try:
539
+ error_val = json.loads(msg["error"])
540
+ except Exception:
541
+ error_val = msg["error"]
542
+
543
+ timestamp_sec = msg["timestamp"] / 1000.0 if msg.get("timestamp") else None
544
+
545
+ json_messages.append(
546
+ {
547
+ "id": msg["id"],
548
+ "direction": msg["direction"],
549
+ "method": msg["method"],
550
+ "timestamp": timestamp_sec,
551
+ "latency_ms": msg["latency_ms"],
552
+ "params": params_val,
553
+ "result": result_val,
554
+ "error": error_val,
555
+ }
556
+ )
557
+ output_str = json.dumps(json_messages, indent=2)
558
+ if output:
559
+ with open(output, "w", encoding="utf-8") as f:
560
+ f.write(output_str + "\n")
561
+ else:
562
+ print(output_str)
563
+ else:
564
+ panels = []
565
+ for msg in messages:
566
+ envelope = rebuild_jsonrpc(msg)
567
+ json_body = json.dumps(envelope, indent=2)
568
+ syntax_body = Syntax(json_body, "json")
569
+
570
+ err_info = error_map.get(msg.get("id"))
571
+ if err_info is None:
572
+ classifier = ErrorClassifier()
573
+ classification = classifier.classify(envelope)
574
+ if classification is not None:
575
+ cat, msg_text, sug = classification
576
+ err_info = {
577
+ "error_type": cat,
578
+ "error_message": msg_text,
579
+ "suggestion": sug,
580
+ }
581
+
582
+ time_str = "unknown"
583
+ if msg.get("timestamp") is not None:
584
+ try:
585
+ dt = datetime.fromtimestamp(msg["timestamp"] / 1000.0)
586
+ time_str = dt.strftime("%H:%M:%S.%f")[:-3]
587
+ except Exception:
588
+ time_str = str(msg["timestamp"])
589
+
590
+ direction_str = msg.get("direction")
591
+ is_error = (msg.get("error") is not None) or (err_info is not None)
592
+
593
+ header = Text()
594
+ if direction_str == "client_to_server":
595
+ header.append("➜ ", style="blue bold")
596
+ header.append("client → server", style="blue")
597
+ border_style = "blue"
598
+ else:
599
+ if is_error:
600
+ header.append("◀ ", style="red bold")
601
+ header.append("server → client", style="red")
602
+ border_style = "red"
603
+ else:
604
+ header.append("◀ ", style="green bold")
605
+ header.append("server → client", style="green")
606
+ border_style = "green"
607
+
608
+ if err_info is not None:
609
+ err_type = err_info.get("error_type") or "unknown"
610
+ badge = f" | [{err_type.upper()} ERROR]"
611
+ header.append(badge, style="red bold")
612
+
613
+ header.append(" | method: ", style="white")
614
+ header.append(msg.get("method") or "unknown", style="yellow bold")
615
+ header.append(" | ", style="white")
616
+ header.append(time_str, style="grey50")
617
+
618
+ if msg.get("message_type") == "response" and msg.get("latency_ms") is not None:
619
+ latency = msg["latency_ms"]
620
+ header.append(" | ", style="white")
621
+ header.append(f"+{latency:.0f}ms", style="magenta bold")
622
+
623
+ if err_info is not None and err_info.get("suggestion"):
624
+ info_lbl = f"{EMOJI_INFO} " if EMOJI_INFO else ""
625
+ suggestion_text = Text(
626
+ f"\n{info_lbl}Suggestion: {err_info['suggestion']}", style="yellow italic"
627
+ )
628
+ panel_content = Group(syntax_body, suggestion_text)
629
+ else:
630
+ panel_content = Group(syntax_body)
631
+
632
+ panel = Panel(
633
+ panel_content,
634
+ title=header,
635
+ title_align="left",
636
+ border_style=border_style,
637
+ safe_box=True,
638
+ )
639
+ panels.append(panel)
640
+
641
+ if output:
642
+ with open(output, "w", encoding="utf-8") as f:
643
+ file_console = Console(file=f, force_terminal=False, color_system=None)
644
+ for panel in panels:
645
+ file_console.print(panel)
646
+ else:
647
+ for panel in panels:
648
+ console.print(panel)
649
+ finally:
650
+ await db.close()
651
+
652
+ try:
653
+ asyncio.run(_run())
654
+ except KeyboardInterrupt:
655
+ sys.exit(0)
656
+
657
+
658
+ @app.command(name="errors")
659
+ def list_errors(
660
+ session_id: int = typer.Argument(..., help="The ID of the session to check for errors"),
661
+ category: Optional[str] = typer.Option(
662
+ None,
663
+ "--category",
664
+ help="Filter errors by category (protocol, tool_execution, timeout, connection, unknown)",
665
+ ),
666
+ json_mode: bool = typer.Option(
667
+ False,
668
+ "--json",
669
+ help="Output raw JSON array of error objects",
670
+ ),
671
+ ) -> None:
672
+ """List and filter classified errors from a specific debugging session."""
673
+
674
+ async def _run() -> None:
675
+ db = Database()
676
+ try:
677
+ try:
678
+ await db.connect()
679
+ session = await db.get_session(session_id)
680
+ except (sqlite3.DatabaseError, aiosqlite.DatabaseError):
681
+ console.print(
682
+ f"[red]Error: Database file at {db.db_path} appears to be corrupted or invalid.[/red]"
683
+ )
684
+ sys.exit(1)
685
+ except Exception as e:
686
+ console.print(f"[red]Error connecting to database: {e}[/red]")
687
+ sys.exit(1)
688
+
689
+ if not session:
690
+ console.print(f"Session {session_id} not found")
691
+ sys.exit(1)
692
+
693
+ try:
694
+ errors = await db.get_errors(session_id)
695
+ except Exception as e:
696
+ console.print(f"[red]Error fetching errors: {e}[/red]")
697
+ sys.exit(1)
698
+
699
+ # If category is provided, filter the errors list
700
+ if category:
701
+ cat_lower = category.lower().strip()
702
+ errors = [e for e in errors if e.get("error_type", "").lower() == cat_lower]
703
+
704
+ if json_mode:
705
+ # Format errors list to standard JSON format
706
+ json_errors = []
707
+ for err in errors:
708
+ json_errors.append(
709
+ {
710
+ "id": err["id"],
711
+ "message_id": err["message_id"],
712
+ "error_code": err["error_code"],
713
+ "error_type": err["error_type"],
714
+ "error_message": err["error_message"],
715
+ "suggestion": err["suggestion"],
716
+ "stack_trace": err["stack_trace"],
717
+ "classified_at": err["classified_at"],
718
+ }
719
+ )
720
+ print(json.dumps(json_errors, indent=2))
721
+ else:
722
+ if not errors:
723
+ console.print(
724
+ f"[yellow]No classified errors found for session {session_id}[/yellow]"
725
+ )
726
+ else:
727
+ table = Table(
728
+ title=f"Classified Errors for Session {session_id}", border_style="red"
729
+ )
730
+ table.add_column("ID", justify="right", style="cyan")
731
+ table.add_column("Type", style="magenta bold")
732
+ table.add_column("Message", style="white")
733
+ table.add_column("Suggestion", style="yellow italic")
734
+
735
+ for err in errors:
736
+ table.add_row(
737
+ str(err["id"]),
738
+ str(err["error_type"]).upper(),
739
+ str(err["error_message"]),
740
+ str(err["suggestion"] or "—"),
741
+ )
742
+ console.print(table)
743
+ finally:
744
+ await db.close()
745
+
746
+ try:
747
+ asyncio.run(_run())
748
+ except KeyboardInterrupt:
749
+ sys.exit(0)
750
+
751
+
752
+ @app.command(name="doctor")
753
+ def doctor() -> None:
754
+ """Run diagnostic checks on the environment and database setup."""
755
+ import shutil
756
+
757
+ lines = []
758
+ critical_failed = False
759
+
760
+ # 1. Python version check
761
+ py_ver = f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}"
762
+ if sys.version_info >= (3, 11):
763
+ lines.append(Text.assemble((EMOJI_CHECK, "green"), f" Python version: {py_ver} (required >=3.11)"))
764
+ else:
765
+ lines.append(
766
+ Text.assemble(
767
+ (EMOJI_CROSS, "red"), f" Python version check: Python 3.11+ required, found {py_ver}"
768
+ )
769
+ )
770
+ critical_failed = True
771
+
772
+ # 2. SQLite check
773
+ try:
774
+ import sqlite3
775
+
776
+ sqlite_ver = sqlite3.sqlite_version
777
+ ver_parts = [int(x) for x in sqlite_ver.split(".")]
778
+ if ver_parts >= [3, 35, 0]:
779
+ lines.append(Text.assemble((EMOJI_CHECK, "green"), f" SQLite version: {sqlite_ver}"))
780
+ else:
781
+ lines.append(
782
+ Text.assemble(
783
+ (EMOJI_CROSS, "red"),
784
+ f" SQLite version check: SQLite version < 3.35.0 (old), found {sqlite_ver}",
785
+ )
786
+ )
787
+ critical_failed = True
788
+ except ImportError:
789
+ lines.append(Text.assemble((EMOJI_CROSS, "red"), " SQLite check: SQLite not available"))
790
+ critical_failed = True
791
+ except Exception as e:
792
+ lines.append(Text.assemble((EMOJI_CROSS, "red"), f" SQLite check: SQLite check failed: {e}"))
793
+ critical_failed = True
794
+
795
+ # 3. Database directory check
796
+ db_dir = Path.home() / ".mcp-debugger"
797
+ if db_dir.exists():
798
+ if os.access(db_dir, os.W_OK):
799
+ lines.append(Text.assemble((EMOJI_CHECK, "green"), f" Database directory: {db_dir} [writable]"))
800
+ else:
801
+ lines.append(
802
+ Text.assemble(
803
+ (EMOJI_CROSS, "red"),
804
+ f" Database directory: Cannot create ~/.mcp-debugger: permission denied at {db_dir}",
805
+ )
806
+ )
807
+ critical_failed = True
808
+ else:
809
+ lines.append(
810
+ Text.assemble(
811
+ (EMOJI_CROSS, "red"),
812
+ f" Database directory: {db_dir} [missing – suggest running: mkdir {db_dir}]",
813
+ )
814
+ )
815
+ critical_failed = True
816
+
817
+ # 4. Database file check
818
+ db_file_path = db_dir / "sessions.db"
819
+ if db_file_path.exists():
820
+ # Check permissions
821
+ if _os_name != "nt":
822
+ try:
823
+ mode = os.stat(db_file_path).st_mode & 0o777
824
+ if mode == 0o600:
825
+ lines.append(
826
+ Text.assemble(
827
+ (EMOJI_CHECK, "green"), f" Database file: {db_file_path} [permissions 600]"
828
+ )
829
+ )
830
+ else:
831
+ lines.append(
832
+ Text.assemble(
833
+ (EMOJI_CROSS, "yellow"),
834
+ f" Database file permissions too open (found {oct(mode)[2:]}, want 600): {db_file_path}",
835
+ )
836
+ )
837
+ except Exception as e:
838
+ lines.append(
839
+ Text.assemble(
840
+ (EMOJI_CROSS, "yellow"),
841
+ f" Database file check: Failed to check DB file permissions: {e}",
842
+ )
843
+ )
844
+ else:
845
+ lines.append(Text.assemble((EMOJI_CHECK, "green"), f" Database file: {db_file_path} [exists]"))
846
+
847
+ # Check schema version
848
+ try:
849
+ conn = sqlite3.connect(db_file_path)
850
+ cursor = conn.cursor()
851
+ cursor.execute("PRAGMA user_version;")
852
+ row = cursor.fetchone()
853
+ user_ver = row[0] if row else 0
854
+ conn.close()
855
+
856
+ if user_ver == 1:
857
+ lines.append(Text.assemble((EMOJI_CHECK, "green"), f" Database schema version: {user_ver}"))
858
+ else:
859
+ lines.append(
860
+ Text.assemble(
861
+ (EMOJI_CROSS, "red"),
862
+ f" Database schema check: Schema version mismatch: expected 1, got {user_ver}",
863
+ )
864
+ )
865
+ critical_failed = True
866
+ except Exception as e:
867
+ lines.append(Text.assemble((EMOJI_CROSS, "red"), f" Database schema check failed: {e}"))
868
+ critical_failed = True
869
+ else:
870
+ lines.append(
871
+ Text.assemble(
872
+ (EMOJI_CHECK, "green"),
873
+ " Database file: no database file found yet (will be created on first proxy run)",
874
+ )
875
+ )
876
+ lines.append(Text.assemble((EMOJI_CHECK, "green"), " Database schema version: not yet created"))
877
+
878
+ # 5. npx check
879
+ npx_path = shutil.which("npx")
880
+ if npx_path:
881
+ lines.append(
882
+ Text.assemble(
883
+ (EMOJI_CHECK, "green"), f" npx command found: {npx_path} (for Node.js MCP servers)"
884
+ )
885
+ )
886
+ else:
887
+ lines.append(
888
+ Text.assemble(
889
+ (EMOJI_CROSS, "yellow"),
890
+ " npx command check: npx not found – MCP servers requiring Node.js may fail",
891
+ )
892
+ )
893
+
894
+ # 6. node check
895
+ node_path = shutil.which("node")
896
+ if node_path:
897
+ lines.append(Text.assemble((EMOJI_CHECK, "green"), f" Node.js found: {node_path}"))
898
+ else:
899
+ lines.append(
900
+ Text.assemble(
901
+ (EMOJI_CROSS, "yellow"),
902
+ " Node.js not found – some MCP servers require Node.js",
903
+ )
904
+ )
905
+
906
+ # 7. git check
907
+ git_path = shutil.which("git")
908
+ if git_path:
909
+ lines.append(Text.assemble((EMOJI_CHECK, "green"), f" git command found: {git_path}"))
910
+ else:
911
+ lines.append(Text.assemble((EMOJI_CHECK, "green"), " git not found (optional)"))
912
+
913
+ # 8. PATH check
914
+ path_dirs = os.environ.get("PATH", "").split(os.pathsep)
915
+ path_summary = ", ".join(path_dirs[:3])
916
+ if len(path_dirs) > 3:
917
+ path_summary += ", ..."
918
+ lines.append(Text.assemble((EMOJI_CHECK, "green"), f" PATH includes: {path_summary}"))
919
+
920
+ # 9. Config file check
921
+ from mcp_debugger.config import Config, default_config_path
922
+
923
+ cfg_path = default_config_path()
924
+ if not cfg_path.exists():
925
+ lines.append(
926
+ Text.assemble((EMOJI_CHECK, "green"), f" Config file: {cfg_path} [not found – using defaults]")
927
+ )
928
+ else:
929
+ try:
930
+ _cfg_check = Config(path=cfg_path)
931
+ _cfg_check.load()
932
+ lines.append(Text.assemble((EMOJI_CHECK, "green"), f" Config file: {cfg_path} [valid]"))
933
+ except Exception as cfg_err:
934
+ lines.append(
935
+ Text.assemble((EMOJI_CROSS, "yellow"), f" Config file: {cfg_path} [invalid: {cfg_err}]")
936
+ )
937
+
938
+ panel_content = Text()
939
+ for idx, line in enumerate(lines):
940
+ if idx > 0:
941
+ panel_content.append("\n")
942
+ panel_content.append(line)
943
+
944
+ title = f"{EMOJI_SEARCH} MCP Debugger Environment Check".strip()
945
+ console.print(
946
+ Panel(
947
+ panel_content,
948
+ title=title,
949
+ title_align="left",
950
+ border_style="red" if critical_failed else "green",
951
+ safe_box=True,
952
+ )
953
+ )
954
+
955
+ if critical_failed:
956
+ raise typer.Exit(code=1)
957
+ else:
958
+ raise typer.Exit(code=0)
959
+
960
+
961
+ def calculate_compliance_score(results: List[Any]) -> Tuple[int, int, int]:
962
+ """Calculates the compliance score based on 5 critical rule categories:
963
+
964
+ 1. jsonrpc_version
965
+ 2. envelope_type (envelope_type, response_envelope, method_format)
966
+ 3. initialize_first
967
+ 4. handshake_order (severity="critical")
968
+ 5. tool_schema_validity (tool_schema_validity, tool_input_schema_format)
969
+
970
+ Returns (score_percentage, passed_count, total_count)
971
+ """
972
+ # If there is a server startup or connection error, score is 0%
973
+ if any(
974
+ r.rule_name in ("server_startup", "server_connection", "handshake_timeout") and not r.passed
975
+ for r in results
976
+ ):
977
+ return 0, 0, 5
978
+
979
+ failed_rules = set()
980
+ for r in results:
981
+ if not r.passed and r.severity == "critical":
982
+ if r.rule_name == "jsonrpc_version":
983
+ failed_rules.add("jsonrpc_version")
984
+ elif r.rule_name in ("envelope_type", "response_envelope", "method_format"):
985
+ failed_rules.add("envelope_type")
986
+ elif r.rule_name == "initialize_first":
987
+ failed_rules.add("initialize_first")
988
+ elif r.rule_name == "handshake_order":
989
+ failed_rules.add("handshake_order")
990
+ elif r.rule_name in ("tool_schema_validity", "tool_input_schema_format"):
991
+ failed_rules.add("tool_schema_validity")
992
+
993
+ passed_count = 5 - len(failed_rules)
994
+ percentage = int((passed_count / 5) * 100)
995
+ return percentage, passed_count, 5
996
+
997
+
998
+ @app.command(name="validate")
999
+ def validate(
1000
+ session_id: Optional[int] = typer.Argument(
1001
+ None, help="The ID of the recorded session to validate"
1002
+ ),
1003
+ server: Optional[str] = typer.Option(
1004
+ None, "--server", "-s", help="Launch a live MCP server command and test it"
1005
+ ),
1006
+ json_mode: bool = typer.Option(
1007
+ False, "--json", help="Output raw JSON array of validation results"
1008
+ ),
1009
+ ) -> None:
1010
+ """Validate MCP protocol compliance of a live server or recorded session."""
1011
+
1012
+ async def _run() -> None:
1013
+ if session_id is not None and server is not None:
1014
+ console.print(
1015
+ "[red]Error: Please specify either a session_id or --server, not both.[/red]"
1016
+ )
1017
+ sys.exit(1)
1018
+ if session_id is None and server is None:
1019
+ console.print(
1020
+ "[red]Error: Please specify a session_id to validate or run a live server validation with --server.[/red]"
1021
+ )
1022
+ sys.exit(1)
1023
+
1024
+ from mcp_debugger.protocol.validator import ProtocolValidator
1025
+ from mcp_debugger.validate_live import run_live_validation
1026
+
1027
+ if server is not None:
1028
+ if not json_mode:
1029
+ search_lbl = f"{EMOJI_SEARCH} " if EMOJI_SEARCH else ""
1030
+ console.print(f"{search_lbl}Validating live server: {server}")
1031
+
1032
+ try:
1033
+ sid, results = await run_live_validation(server)
1034
+ except Exception as e:
1035
+ console.print(f"[red]Error during live validation: {e}[/red]")
1036
+ sys.exit(1)
1037
+ else:
1038
+ if session_id is None:
1039
+ console.print("[red]Error: session_id is required.[/red]")
1040
+ sys.exit(1)
1041
+
1042
+ db = Database()
1043
+ try:
1044
+ try:
1045
+ await db.connect()
1046
+ session = await db.get_session(session_id)
1047
+ except Exception as e:
1048
+ console.print(f"[red]Error connecting to database: {e}[/red]")
1049
+ sys.exit(1)
1050
+
1051
+ if not session:
1052
+ console.print(f"[red]Error: Session #{session_id} not found.[/red]")
1053
+ sys.exit(1)
1054
+
1055
+ if not json_mode:
1056
+ search_lbl = f"{EMOJI_SEARCH} " if EMOJI_SEARCH else ""
1057
+ console.print(f"{search_lbl}Validating recorded session #{session_id}")
1058
+
1059
+ try:
1060
+ validator = ProtocolValidator()
1061
+ results = await validator.validate_session(session_id, db)
1062
+ except Exception as e:
1063
+ console.print(f"[red]Error validating session: {e}[/red]")
1064
+ sys.exit(1)
1065
+ finally:
1066
+ await db.close()
1067
+
1068
+ # Render results
1069
+ if json_mode:
1070
+ json_results = []
1071
+ for r in results:
1072
+ try:
1073
+ json_results.append(r.model_dump())
1074
+ except AttributeError:
1075
+ # pyrefly: ignore [deprecated]
1076
+ json_results.append(r.dict())
1077
+ print(json.dumps(json_results, indent=2))
1078
+ else:
1079
+ table = Table(
1080
+ title="Validation Results",
1081
+ border_style="magenta",
1082
+ )
1083
+ table.add_column("Rule", style="cyan bold")
1084
+ table.add_column("Severity", style="white")
1085
+ table.add_column("Message", style="white")
1086
+
1087
+ has_critical = False
1088
+ critical_count = 0
1089
+ warning_count = 0
1090
+
1091
+ for r in results:
1092
+ if not r.passed:
1093
+ if r.severity == "critical":
1094
+ severity_text = "[red]🔴 CRIT[/red]"
1095
+ has_critical = True
1096
+ critical_count += 1
1097
+ elif r.severity == "warning":
1098
+ severity_text = "[yellow]🟡 WARN[/yellow]"
1099
+ warning_count += 1
1100
+ else:
1101
+ severity_text = "[blue]🔵 INFO[/blue]"
1102
+ else:
1103
+ severity_text = "[green]✓ PASS[/green]"
1104
+
1105
+ msg_detail = r.message
1106
+ if r.suggestion:
1107
+ msg_detail += f"\n[yellow]→ Suggestion: {r.suggestion}[/yellow]"
1108
+
1109
+ table.add_row(r.rule_name, severity_text, msg_detail)
1110
+
1111
+ console.print(table)
1112
+
1113
+ score, passed, total = calculate_compliance_score(results)
1114
+
1115
+ if has_critical:
1116
+ console.print(
1117
+ f"\n[red]Overall compliance: {critical_count} critical failures, {warning_count} warnings.[/red]"
1118
+ )
1119
+ console.print(
1120
+ f"Compliance score: {score}% ({passed}/{total} critical rules passed)"
1121
+ )
1122
+ sys.exit(1)
1123
+ else:
1124
+ console.print(
1125
+ f"\n[green]Overall compliance: 0 critical failures, {warning_count} warnings.[/green]"
1126
+ )
1127
+ console.print(
1128
+ f"Compliance score: {score}% ({passed}/{total} critical rules passed)"
1129
+ )
1130
+ sys.exit(0)
1131
+
1132
+ try:
1133
+ asyncio.run(_run())
1134
+ except KeyboardInterrupt:
1135
+ sys.exit(0)
1136
+
1137
+
1138
+ @app.command(name="tools")
1139
+ def tools(
1140
+ session_id: int = typer.Argument(..., help="The ID of the session to view tools for"),
1141
+ detail: Optional[str] = typer.Option(
1142
+ None,
1143
+ "--detail",
1144
+ help="Show the full input schema for a specific tool",
1145
+ ),
1146
+ json_mode: bool = typer.Option(
1147
+ False,
1148
+ "--json",
1149
+ help="Output raw JSON array of tools for scripting",
1150
+ ),
1151
+ ) -> None:
1152
+ """View discovered tools and their usage schemas/call counts for a session."""
1153
+
1154
+ async def _run() -> None:
1155
+ db = Database()
1156
+ try:
1157
+ try:
1158
+ await db.connect()
1159
+ session = await db.get_session(session_id)
1160
+ except (sqlite3.DatabaseError, aiosqlite.DatabaseError):
1161
+ console.print(
1162
+ f"[red]Error: Database file at {db.db_path} appears to be corrupted or invalid.[/red]"
1163
+ )
1164
+ console.print(
1165
+ "[yellow]Recovery Suggestion: Try deleting or renaming the file to reset the database.[/yellow]"
1166
+ )
1167
+ sys.exit(1)
1168
+ except Exception as e:
1169
+ console.print(f"[red]Error connecting to database: {e}[/red]")
1170
+ sys.exit(1)
1171
+
1172
+ if not session:
1173
+ console.print(f"Session {session_id} not found")
1174
+ sys.exit(1)
1175
+
1176
+ try:
1177
+ tools_list = await db.get_tools(session_id)
1178
+ except Exception as e:
1179
+ console.print(f"[red]Error fetching tools: {e}[/red]")
1180
+ sys.exit(1)
1181
+
1182
+ if not tools_list:
1183
+ if json_mode:
1184
+ print("[]")
1185
+ else:
1186
+ console.print("No tools discovered in this session")
1187
+ sys.exit(1)
1188
+
1189
+ # If detailed view requested for a specific tool
1190
+ if detail:
1191
+ target_tool = next((t for t in tools_list if t["name"] == detail), None)
1192
+ if not target_tool:
1193
+ console.print(f"Tool {detail} not found in this session")
1194
+ sys.exit(1)
1195
+
1196
+ try:
1197
+ schema_dict = json.loads(target_tool["input_schema"])
1198
+ except Exception:
1199
+ schema_dict = target_tool["input_schema"]
1200
+
1201
+ if json_mode:
1202
+ print(json.dumps(schema_dict, indent=2))
1203
+ else:
1204
+ syntax_schema = Syntax(json.dumps(schema_dict, indent=2), "json")
1205
+ title = f"{EMOJI_WRENCH} Tool Schema: {detail}".strip()
1206
+ console.print(
1207
+ Panel(
1208
+ syntax_schema,
1209
+ title=title,
1210
+ title_align="left",
1211
+ border_style="magenta",
1212
+ safe_box=True,
1213
+ )
1214
+ )
1215
+ return
1216
+
1217
+ # Fetch usage counts
1218
+ tools_with_calls = []
1219
+ for t in tools_list:
1220
+ calls_count = await db.get_tool_usage_count(session_id, t["name"])
1221
+ try:
1222
+ schema_dict = json.loads(t["input_schema"])
1223
+ except Exception:
1224
+ schema_dict = t["input_schema"]
1225
+
1226
+ tools_with_calls.append(
1227
+ {
1228
+ "name": t["name"],
1229
+ "description": t["description"],
1230
+ "input_schema": schema_dict,
1231
+ "calls": calls_count,
1232
+ }
1233
+ )
1234
+
1235
+ if json_mode:
1236
+ print(json.dumps(tools_with_calls, indent=2))
1237
+ else:
1238
+ session_name_part = (
1239
+ f" ({session['friendly_name']})" if session.get("friendly_name") else ""
1240
+ )
1241
+ table = Table(
1242
+ title=f"Tools discovered in session #{session_id}{session_name_part}",
1243
+ border_style="magenta",
1244
+ )
1245
+ table.add_column("Name", style="cyan bold")
1246
+ table.add_column("Description", style="white")
1247
+ table.add_column("Calls", justify="right", style="green")
1248
+
1249
+ for tc in tools_with_calls:
1250
+ table.add_row(
1251
+ str(tc["name"]),
1252
+ str(tc["description"] or "—"),
1253
+ str(tc["calls"]),
1254
+ )
1255
+ console.print(table)
1256
+ finally:
1257
+ await db.close()
1258
+
1259
+ try:
1260
+ asyncio.run(_run())
1261
+ except KeyboardInterrupt:
1262
+ sys.exit(0)
1263
+
1264
+
1265
+ def generate_markdown_report(stats_data: Any, limit: int) -> str:
1266
+ duration_str = "N/A"
1267
+ if stats_data.duration_seconds is not None:
1268
+ m, s = divmod(stats_data.duration_seconds, 60)
1269
+ duration_str = f"{m}m {s}s" if m > 0 else f"{s}s"
1270
+
1271
+ lines = [
1272
+ f"# Session Statistics Report - Session #{stats_data.session_id}",
1273
+ "",
1274
+ f"- **Friendly Name**: {stats_data.friendly_name or '—'}",
1275
+ f"- **Server Command**: `{stats_data.server_command}`",
1276
+ f"- **Status**: {stats_data.status}",
1277
+ f"- **Duration**: {duration_str}",
1278
+ f"- **Total Messages**: {stats_data.total_messages} ({stats_data.client_to_server_count} client-to-server, {stats_data.server_to_client_count} server-to-client)",
1279
+ "",
1280
+ "## Top Tools",
1281
+ "| Tool | Calls | Avg Latency | Error Rate |",
1282
+ "| :--- | :---: | :---: | :---: |",
1283
+ ]
1284
+
1285
+ for tool in stats_data.top_tools[:limit]:
1286
+ avg_lat = f"{tool.avg_latency_ms:.1f}ms" if tool.avg_latency_ms is not None else "—"
1287
+ err_rate_str = f"{tool.error_rate * 100:.0f}%"
1288
+ if tool.errors_count > 0:
1289
+ err_rate_str += f" ({tool.errors_count} error{'s' if tool.errors_count > 1 else ''})"
1290
+ lines.append(f"| {tool.name} | {tool.calls} | {avg_lat} | {err_rate_str} |")
1291
+
1292
+ lines.extend(
1293
+ [
1294
+ "",
1295
+ "## Latency Metrics",
1296
+ f"- **Min Latency**: {f'{stats_data.latency_min:.1f}ms' if stats_data.latency_min is not None else 'N/A'}",
1297
+ f"- **Max Latency**: {f'{stats_data.latency_max:.1f}ms' if stats_data.latency_max is not None else 'N/A'}",
1298
+ f"- **Avg Latency**: {f'{stats_data.latency_avg:.1f}ms' if stats_data.latency_avg is not None else 'N/A'}",
1299
+ "",
1300
+ "## Errors by Category",
1301
+ ]
1302
+ )
1303
+
1304
+ if not stats_data.errors_by_category:
1305
+ lines.append("No errors recorded.")
1306
+ else:
1307
+ for cat, count in stats_data.errors_by_category.items():
1308
+ lines.append(f"- **{cat}**: {count}")
1309
+
1310
+ lines.extend(
1311
+ [
1312
+ "",
1313
+ "## Method Distribution",
1314
+ "| Method | Count | Percentage |",
1315
+ "| :--- | :---: | :---: |",
1316
+ ]
1317
+ )
1318
+
1319
+ total_methods = sum(stats_data.method_distribution.values())
1320
+ for method, count in sorted(
1321
+ stats_data.method_distribution.items(), key=lambda x: x[1], reverse=True
1322
+ ):
1323
+ pct = (count / total_methods) * 100 if total_methods > 0 else 0.0
1324
+ lines.append(f"| {method} | {count} | {pct:.1f}% |")
1325
+
1326
+ return "\n".join(lines)
1327
+
1328
+
1329
+ @app.command(name="stats")
1330
+ def stats(
1331
+ session_id: int = typer.Argument(..., help="The ID of the session to view statistics for"),
1332
+ limit: int = typer.Option(
1333
+ 10,
1334
+ "--limit",
1335
+ help="Number of top tools to show",
1336
+ ),
1337
+ json_mode: bool = typer.Option(
1338
+ False,
1339
+ "--json",
1340
+ help="Output raw statistics as JSON",
1341
+ ),
1342
+ output: Optional[str] = typer.Option(
1343
+ None,
1344
+ "--output",
1345
+ help="Write report to a file (Markdown or JSON)",
1346
+ ),
1347
+ ) -> None:
1348
+ """Display a comprehensive statistical dashboard for a single session."""
1349
+
1350
+ async def _run() -> None:
1351
+ db = Database()
1352
+ try:
1353
+ try:
1354
+ await db.connect()
1355
+ except Exception as e:
1356
+ console.print(f"[red]Error connecting to database: {e}[/red]")
1357
+ sys.exit(1)
1358
+
1359
+ try:
1360
+ stats_data = await aggregate_session_stats(db, session_id)
1361
+ except ValueError as e:
1362
+ console.print(f"[red]Error: {e}[/red]")
1363
+ sys.exit(1)
1364
+ except Exception as e:
1365
+ console.print(f"[red]Error aggregating statistics: {e}[/red]")
1366
+ sys.exit(1)
1367
+ finally:
1368
+ await db.close()
1369
+
1370
+ # Handle --json mode
1371
+ if json_mode:
1372
+ stats_json = stats_data.model_dump_json(indent=2)
1373
+ print(stats_json)
1374
+ if output:
1375
+ try:
1376
+ Path(output).write_text(stats_json, encoding="utf-8")
1377
+ except Exception as e:
1378
+ console.print(f"[red]Error writing to output file: {e}[/red]")
1379
+ return
1380
+
1381
+ # Handle file output if specified (and not in JSON mode)
1382
+ if output:
1383
+ try:
1384
+ out_path = Path(output)
1385
+ if out_path.suffix == ".json":
1386
+ out_path.write_text(stats_data.model_dump_json(indent=2), encoding="utf-8")
1387
+ else:
1388
+ # Generate markdown report
1389
+ md = generate_markdown_report(stats_data, limit)
1390
+ out_path.write_text(md, encoding="utf-8")
1391
+ except Exception as e:
1392
+ console.print(f"[red]Error writing output file: {e}[/red]")
1393
+
1394
+ # RENDER TO TERMINAL
1395
+ # Session Header Panel
1396
+ status_style = (
1397
+ "green"
1398
+ if stats_data.status == "completed"
1399
+ else ("red" if stats_data.status == "error" else "yellow")
1400
+ )
1401
+
1402
+ duration_str = "N/A"
1403
+ if stats_data.duration_seconds is not None:
1404
+ m, s = divmod(stats_data.duration_seconds, 60)
1405
+ duration_str = f"{m}m {s}s" if m > 0 else f"{s}s"
1406
+
1407
+ header_lines = [
1408
+ f"Server: [cyan]{stats_data.server_command}[/cyan]",
1409
+ f"Status: [{status_style}]{stats_data.status}[/{status_style}]",
1410
+ f"Started: {stats_data.started_at or 'N/A'} | Ended: {stats_data.ended_at or 'Ongoing'} | Duration: {duration_str}",
1411
+ f"Messages: {stats_data.total_messages} total ({stats_data.client_to_server_count} → server, {stats_data.server_to_client_count} ← client)",
1412
+ ]
1413
+
1414
+ title_friendly = f' - "{stats_data.friendly_name}"' if stats_data.friendly_name else ""
1415
+ console.print(
1416
+ Panel(
1417
+ "\n".join(header_lines),
1418
+ title=f"Session #{stats_data.session_id}{title_friendly}",
1419
+ border_style="blue",
1420
+ safe_box=True,
1421
+ )
1422
+ )
1423
+
1424
+ # Top Tools Table
1425
+ chart_lbl = f"{EMOJI_CHART} " if EMOJI_CHART else ""
1426
+ console.print(f"\n{chart_lbl}[bold]Top Tools[/bold]")
1427
+ if not stats_data.top_tools:
1428
+ console.print("No tools called in this session.")
1429
+ else:
1430
+ table = Table(border_style="magenta")
1431
+ table.add_column("Tool", style="cyan bold")
1432
+ table.add_column("Calls", justify="right")
1433
+ table.add_column("Avg Latency", justify="right")
1434
+ table.add_column("Error Rate", justify="right")
1435
+
1436
+ for tool in stats_data.top_tools[:limit]:
1437
+ avg_lat = f"{tool.avg_latency_ms:.1f}ms" if tool.avg_latency_ms is not None else "—"
1438
+ err_rate_val = tool.error_rate * 100
1439
+ err_rate_str = f"{err_rate_val:.0f}%"
1440
+ if tool.errors_count > 0:
1441
+ err_rate_str += (
1442
+ f" ({tool.errors_count} error{'s' if tool.errors_count > 1 else ''})"
1443
+ )
1444
+ err_style = "red" if tool.errors_count > 0 else "green"
1445
+
1446
+ table.add_row(
1447
+ tool.name,
1448
+ str(tool.calls),
1449
+ avg_lat,
1450
+ f"[{err_style}]{err_rate_str}[/{err_style}]",
1451
+ )
1452
+ console.print(table)
1453
+
1454
+ # Latency Trend
1455
+ console.print("\n📈 [bold]Latency Trend[/bold] (response time over time)")
1456
+ if not stats_data.latency_trend:
1457
+ console.print("No latency data available.")
1458
+ else:
1459
+ spark = generate_sparkline(stats_data.latency_trend, width=30)
1460
+ min_l = (
1461
+ f"{stats_data.latency_min:.1f}ms" if stats_data.latency_min is not None else "N/A"
1462
+ )
1463
+ max_l = (
1464
+ f"{stats_data.latency_max:.1f}ms" if stats_data.latency_max is not None else "N/A"
1465
+ )
1466
+ avg_l = (
1467
+ f"{stats_data.latency_avg:.1f}ms" if stats_data.latency_avg is not None else "N/A"
1468
+ )
1469
+ console.print(f"{spark} (min {min_l}, max {max_l}, avg {avg_l})")
1470
+
1471
+ # Errors by Category
1472
+ console.print("\n⚠️ [bold]Errors by Category[/bold]")
1473
+ if not stats_data.errors_by_category:
1474
+ console.print("No errors recorded.")
1475
+ else:
1476
+ err_chart = generate_bar_chart(stats_data.errors_by_category, max_width=20)
1477
+ for label, count, pct, bar_str in err_chart:
1478
+ console.print(f"{label}: {count} [red]{bar_str}[/red] ({pct * 100:.0f}%)")
1479
+
1480
+ # Method Distribution
1481
+ console.print("\n🔁 [bold]Method Distribution[/bold]")
1482
+ if not stats_data.method_distribution:
1483
+ console.print("No methods recorded.")
1484
+ else:
1485
+ method_chart = generate_bar_chart(stats_data.method_distribution, max_width=20)
1486
+ for label, count, pct, bar_str in method_chart:
1487
+ console.print(f"{label}: {count} [blue]{bar_str}[/blue] ({pct * 100:.0f}%)")
1488
+
1489
+ # Error Trend Sparkline
1490
+ console.print("\n📈 [bold]Error Trend[/bold] (error density over time)")
1491
+ if not stats_data.error_trend:
1492
+ console.print("No responses recorded to track errors.")
1493
+ else:
1494
+ err_spark = generate_sparkline([float(x) for x in stats_data.error_trend], width=30)
1495
+ total_err = sum(stats_data.errors_by_category.values())
1496
+ console.print(f"{err_spark} ({total_err} total errors)")
1497
+
1498
+ try:
1499
+ asyncio.run(_run())
1500
+ except KeyboardInterrupt:
1501
+ sys.exit(0)
1502
+
1503
+
1504
+ @app.command(name="compare")
1505
+ def compare(
1506
+ session_id_a: int = typer.Argument(..., help="The ID of the baseline session (old)"),
1507
+ session_id_b: int = typer.Argument(
1508
+ ..., help="The ID of the target session to compare against (new)"
1509
+ ),
1510
+ json_mode: bool = typer.Option(
1511
+ False,
1512
+ "--json",
1513
+ help="Output raw comparison statistics as JSON",
1514
+ ),
1515
+ ) -> None:
1516
+ """Highlight differences between two debugging sessions."""
1517
+
1518
+ async def _run() -> None:
1519
+ db = Database()
1520
+ try:
1521
+ try:
1522
+ await db.connect()
1523
+ except Exception as e:
1524
+ console.print(f"[red]Error connecting to database: {e}[/red]")
1525
+ sys.exit(1)
1526
+
1527
+ try:
1528
+ stats_a = await aggregate_session_stats(db, session_id_a)
1529
+ stats_b = await aggregate_session_stats(db, session_id_b)
1530
+ except ValueError as e:
1531
+ console.print(f"[red]Error: {e}[/red]")
1532
+ sys.exit(1)
1533
+ except Exception as e:
1534
+ console.print(f"[red]Error aggregating session statistics: {e}[/red]")
1535
+ sys.exit(1)
1536
+ finally:
1537
+ await db.close()
1538
+
1539
+ comparison = compare_sessions_stats(stats_a, stats_b)
1540
+
1541
+ if json_mode:
1542
+ print(comparison.model_dump_json(indent=2))
1543
+ return
1544
+
1545
+ # RENDER COMPARISON TO TERMINAL
1546
+ console.print(
1547
+ f"[bold]Comparing session #{session_id_a} (old) vs #{session_id_b} (new)[/bold]\n"
1548
+ )
1549
+
1550
+ # Duration change
1551
+ dur_a_str = (
1552
+ f"{stats_a.duration_seconds}s" if stats_a.duration_seconds is not None else "N/A"
1553
+ )
1554
+ dur_b_str = (
1555
+ f"{stats_b.duration_seconds}s" if stats_b.duration_seconds is not None else "N/A"
1556
+ )
1557
+
1558
+ dur_color = "white"
1559
+ if comparison.duration_change_pct is not None:
1560
+ if comparison.duration_change_pct < 0:
1561
+ dur_color = "green"
1562
+ elif comparison.duration_change_pct > 0:
1563
+ dur_color = "red"
1564
+
1565
+ console.print(
1566
+ f"Duration: {dur_a_str} → {dur_b_str} ([{dur_color}]{comparison.duration_change_str}[/{dur_color}])"
1567
+ )
1568
+
1569
+ # Messages change
1570
+ msg_diff_str = (
1571
+ f"{comparison.messages_change_abs:+d} messages"
1572
+ if comparison.messages_change_abs != 0
1573
+ else "no change"
1574
+ )
1575
+ console.print(
1576
+ f"Total messages: {comparison.messages_a} → {comparison.messages_b} ({msg_diff_str})\n"
1577
+ )
1578
+
1579
+ # Tool Call Changes Table
1580
+ chart_lbl = f"{EMOJI_CHART} " if EMOJI_CHART else ""
1581
+ console.print(f"{chart_lbl}[bold]Tool Call Changes[/bold]")
1582
+ if not comparison.tool_changes:
1583
+ console.print("No tool call changes recorded.")
1584
+ else:
1585
+ table = Table(border_style="magenta")
1586
+ table.add_column("Tool", style="cyan bold")
1587
+ table.add_column("Old Calls", justify="right")
1588
+ table.add_column("New Calls", justify="right")
1589
+ table.add_column("Change", justify="right")
1590
+ table.add_column("Avg Latency (Old → New)", justify="right")
1591
+
1592
+ for tc in comparison.tool_changes:
1593
+ # Color code change string
1594
+ if "new" in tc.change_str:
1595
+ change_style = "green bold"
1596
+ elif "removed" in tc.change_str:
1597
+ change_style = "red bold"
1598
+ elif "+" in tc.change_str:
1599
+ change_style = "blue"
1600
+ elif "-" in tc.change_str:
1601
+ change_style = "yellow"
1602
+ else:
1603
+ change_style = "white"
1604
+
1605
+ lat_a = f"{tc.avg_latency_a:.1f}ms" if tc.avg_latency_a is not None else "—"
1606
+ lat_b = f"{tc.avg_latency_b:.1f}ms" if tc.avg_latency_b is not None else "—"
1607
+
1608
+ lat_change_str = ""
1609
+ if tc.avg_latency_change_pct is not None:
1610
+ if tc.avg_latency_change_pct < 0:
1611
+ lat_change_str = (
1612
+ f" [green](↓ {abs(tc.avg_latency_change_pct):.0f}% faster)[/green]"
1613
+ )
1614
+ elif tc.avg_latency_change_pct > 0:
1615
+ lat_change_str = (
1616
+ f" [red](↑ {abs(tc.avg_latency_change_pct):.0f}% slower)[/red]"
1617
+ )
1618
+
1619
+ table.add_row(
1620
+ tc.name,
1621
+ str(tc.calls_a),
1622
+ str(tc.calls_b),
1623
+ f"[{change_style}]{tc.change_str}[/{change_style}]",
1624
+ f"{lat_a} → {lat_b}{lat_change_str}",
1625
+ )
1626
+ console.print(table)
1627
+
1628
+ # Error Rate Change
1629
+ console.print("\n⚠️ [bold]Error Rate Change[/bold]")
1630
+ err_color = "white"
1631
+ if "improvement" in comparison.error_rate_change_str:
1632
+ err_color = "green"
1633
+ elif "regression" in comparison.error_rate_change_str:
1634
+ err_color = "red"
1635
+
1636
+ console.print(
1637
+ f"Old: {comparison.error_rate_a:.1f}% ({comparison.errors_a} errors) → "
1638
+ f"New: {comparison.error_rate_b:.1f}% ({comparison.errors_b} errors) "
1639
+ f"([{err_color}]{comparison.error_rate_change_str}[/{err_color}])\n"
1640
+ )
1641
+
1642
+ # Dynamic Summary statement
1643
+ summary_parts = []
1644
+ if comparison.duration_change_pct is not None and comparison.duration_change_pct < -5:
1645
+ summary_parts.append("is faster")
1646
+ elif comparison.duration_change_pct is not None and comparison.duration_change_pct > 5:
1647
+ summary_parts.append("is slower")
1648
+
1649
+ if comparison.errors_b < comparison.errors_a:
1650
+ summary_parts.append("has fewer errors")
1651
+ elif comparison.errors_b > comparison.errors_a:
1652
+ summary_parts.append("has more errors")
1653
+
1654
+ summary_text = ""
1655
+ if summary_parts:
1656
+ summary_text = f"Session #{session_id_b} " + " and ".join(summary_parts) + "."
1657
+ else:
1658
+ summary_text = f"Session #{session_id_b} has similar performance and error rates compared to #{session_id_a}."
1659
+
1660
+ # Add warnings about removed tools or slower tools
1661
+ warnings = []
1662
+ for tc in comparison.tool_changes:
1663
+ if "removed" in tc.change_str:
1664
+ warnings.append(f"tool '{tc.name}' was removed")
1665
+ elif tc.avg_latency_change_pct is not None and tc.avg_latency_change_pct > 20:
1666
+ warnings.append(
1667
+ f"tool '{tc.name}' got significantly slower (+{tc.avg_latency_change_pct:.0f}%)"
1668
+ )
1669
+
1670
+ if warnings:
1671
+ summary_text += " [yellow]Verify changes: " + ", ".join(warnings) + ".[/yellow]"
1672
+
1673
+ info_lbl = f"{EMOJI_INFO} " if EMOJI_INFO else ""
1674
+ console.print(f"{info_lbl}[bold]Summary:[/bold] {summary_text}")
1675
+
1676
+ try:
1677
+ asyncio.run(_run())
1678
+ except KeyboardInterrupt:
1679
+ sys.exit(0)
1680
+
1681
+
1682
+ @app.command(name="export")
1683
+ def export(
1684
+ session_id: int = typer.Argument(..., help="The ID of the session to export"),
1685
+ format: str = typer.Option(
1686
+ "json",
1687
+ "--format",
1688
+ help="Export format: json | markdown | otlp",
1689
+ ),
1690
+ output: Optional[str] = typer.Option(
1691
+ None,
1692
+ "--output",
1693
+ help="Write to file instead of stdout (json / markdown formats)",
1694
+ ),
1695
+ pretty: bool = typer.Option(
1696
+ False,
1697
+ "--pretty",
1698
+ help="Pretty-print JSON / indent markdown raw blocks",
1699
+ ),
1700
+ include_raw: bool = typer.Option(
1701
+ False,
1702
+ "--include-raw",
1703
+ help="Include raw message JSON in markdown <details> blocks",
1704
+ ),
1705
+ endpoint: str = typer.Option(
1706
+ "http://localhost:4317",
1707
+ "--endpoint",
1708
+ help="OTLP collector endpoint (otlp format only)",
1709
+ ),
1710
+ insecure: bool = typer.Option(
1711
+ True,
1712
+ "--insecure",
1713
+ help="Disable TLS (for local OTLP testing)",
1714
+ ),
1715
+ service_name: str = typer.Option(
1716
+ "mcp-debugger",
1717
+ "--service-name",
1718
+ help="Service name for OTLP traces",
1719
+ ),
1720
+ limit: Optional[int] = typer.Option(
1721
+ None,
1722
+ "--limit",
1723
+ help="Max messages to export (useful for large sessions with otlp)",
1724
+ ),
1725
+ ) -> None:
1726
+ """Export session data as JSON, Markdown, or OpenTelemetry (OTLP) traces."""
1727
+
1728
+ # Config fallbacks: export.default_format and export.pretty_json
1729
+ from mcp_debugger.config import get_config
1730
+
1731
+ _cfg = get_config()
1732
+ # Only apply config default when the user didn't explicitly pass --format
1733
+ # (typer default is "json" so we can't distinguish; treat "json" as config-eligible)
1734
+ effective_format = (
1735
+ format if format != "json" else str(_cfg.get("export.default_format", "json"))
1736
+ )
1737
+ effective_pretty = pretty or bool(_cfg.get("export.pretty_json", False))
1738
+
1739
+ fmt = effective_format.lower().strip()
1740
+ if fmt not in {"json", "markdown", "otlp"}:
1741
+ console.print(
1742
+ f"[red]Error: unknown format '{effective_format}'. Choose json, markdown, or otlp.[/red]"
1743
+ )
1744
+ sys.exit(1)
1745
+
1746
+ async def _run() -> None:
1747
+ db = Database()
1748
+ try:
1749
+ try:
1750
+ await db.connect()
1751
+ except Exception as e:
1752
+ console.print(f"[red]Error connecting to database: {e}[/red]")
1753
+ sys.exit(1)
1754
+
1755
+ session = await db.get_session(session_id)
1756
+ if not session:
1757
+ console.print(f"[red]Error: Session #{session_id} not found.[/red]")
1758
+ sys.exit(1)
1759
+
1760
+ messages = await db.get_messages(session_id, limit=limit)
1761
+ tools = await db.get_tools(session_id)
1762
+ errors = await db.get_errors(session_id)
1763
+
1764
+ try:
1765
+ from mcp_debugger.analytics import aggregate_session_stats as _agg
1766
+
1767
+ stats = await _agg(db, session_id)
1768
+ except Exception as e:
1769
+ console.print(f"[red]Error computing session stats: {e}[/red]")
1770
+ sys.exit(1)
1771
+ finally:
1772
+ await db.close()
1773
+
1774
+ # ---- OTLP -----------------------------------------------------------
1775
+ if fmt == "otlp":
1776
+ try:
1777
+ from mcp_debugger.exporters.otlp_exporter import OTLPExporter
1778
+ except ImportError as exc:
1779
+ console.print(f"[red]{exc}[/red]")
1780
+ sys.exit(1)
1781
+ try:
1782
+ exporter_otlp = OTLPExporter(
1783
+ endpoint=endpoint,
1784
+ insecure=insecure,
1785
+ service_name=service_name,
1786
+ limit=limit,
1787
+ )
1788
+ span_count = exporter_otlp.export(dict(session), messages)
1789
+ console.print(f"[green]Exported {span_count} span(s) to {endpoint}[/green]")
1790
+ except Exception as e:
1791
+ console.print(f"[yellow]Warning: OTLP export failed: {e}[/yellow]")
1792
+ return
1793
+
1794
+ # ---- JSON / Markdown ------------------------------------------------
1795
+ if fmt == "json":
1796
+ from mcp_debugger.exporters.json_exporter import JSONExporter
1797
+
1798
+ exporter_obj: Any = JSONExporter(pretty=effective_pretty, include_raw=include_raw)
1799
+ else:
1800
+ from mcp_debugger.exporters.markdown_exporter import MarkdownExporter
1801
+
1802
+ exporter_obj = MarkdownExporter(include_raw=include_raw, pretty=effective_pretty)
1803
+
1804
+ if output:
1805
+ out_path = Path(output)
1806
+ try:
1807
+ with out_path.open("w", encoding="utf-8") as f:
1808
+ exporter_obj.export(dict(session), messages, tools, errors, stats, f)
1809
+ console.print(f"[green]Exported to {out_path.resolve()}[/green]")
1810
+ except Exception as e:
1811
+ console.print(f"[red]Error writing to {output}: {e}[/red]")
1812
+ sys.exit(1)
1813
+ else:
1814
+ buf = io.StringIO()
1815
+ exporter_obj.export(dict(session), messages, tools, errors, stats, buf)
1816
+ print(buf.getvalue())
1817
+
1818
+ try:
1819
+ asyncio.run(_run())
1820
+ except KeyboardInterrupt:
1821
+ sys.exit(0)
1822
+
1823
+
1824
+ @app.command(name="replay")
1825
+ def replay(
1826
+ session_id: int = typer.Argument(..., help="ID of the recorded session to replay"),
1827
+ server: Optional[str] = typer.Option(
1828
+ None,
1829
+ "--server",
1830
+ "-s",
1831
+ help="Command to launch the target server (overrides --alias and config)",
1832
+ ),
1833
+ alias: Optional[str] = typer.Option(
1834
+ None, "--alias", "-a", help="Server alias defined in config [aliases] section"
1835
+ ),
1836
+ timeout: Optional[int] = typer.Option(
1837
+ None, "--timeout", help="Timeout in milliseconds per request-response pair"
1838
+ ),
1839
+ max_messages: Optional[int] = typer.Option(
1840
+ None, "--max-messages", help="Maximum number of client messages to replay"
1841
+ ),
1842
+ filter_method: Optional[str] = typer.Option(
1843
+ None, "--filter-method", help="Only replay messages with this method name"
1844
+ ),
1845
+ verbose: bool = typer.Option(
1846
+ False, "--verbose", "-v", help="Show all messages with diffs (even matches)"
1847
+ ),
1848
+ json_mode: bool = typer.Option(False, "--json", help="Output raw JSON report"),
1849
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write output to a file"),
1850
+ save: bool = typer.Option(
1851
+ False, "--save", help="Save replay results to the replays database table"
1852
+ ),
1853
+ no_diff: bool = typer.Option(
1854
+ False, "--no-diff", help="Skip detailed diff output (only show summary)"
1855
+ ),
1856
+ otlp_export: bool = typer.Option(
1857
+ False,
1858
+ "--otlp-export",
1859
+ help="Export replay results to an OTLP collector (requires mcp-debugger[otlp])",
1860
+ ),
1861
+ otlp_endpoint: Optional[str] = typer.Option(
1862
+ None, "--otlp-endpoint", help="OTLP gRPC collector endpoint"
1863
+ ),
1864
+ otlp_insecure: bool = typer.Option(
1865
+ True, "--otlp-insecure/--otlp-tls", help="Disable TLS for local OTLP collectors"
1866
+ ),
1867
+ otlp_service_name: Optional[str] = typer.Option(
1868
+ None, "--otlp-service-name", help="Service name for OTLP traces"
1869
+ ),
1870
+ ) -> None:
1871
+ """Replay client messages from a recorded session against a target server."""
1872
+
1873
+ # ------------------------------------------------------------------
1874
+ # Config fallbacks (CLI flags override, then config, then hardcoded defaults)
1875
+ # ------------------------------------------------------------------
1876
+ from mcp_debugger.config import get_config
1877
+
1878
+ _cfg = get_config()
1879
+
1880
+ # Resolve server: --server > --alias > config.replay.default_server
1881
+ effective_server = server
1882
+ if effective_server is None and alias is not None:
1883
+ effective_server = _cfg.resolve_alias(alias)
1884
+ if effective_server is None:
1885
+ console.print(f"[red]Alias '{alias}' not found in config [aliases] section.[/red]")
1886
+ sys.exit(1)
1887
+ if effective_server is None:
1888
+ effective_server = _cfg.get("replay.default_server", "") or None
1889
+ if not effective_server:
1890
+ console.print(
1891
+ "[red]Error: No server specified. Use --server, --alias, or set replay.default_server in config.[/red]"
1892
+ )
1893
+ sys.exit(1)
1894
+
1895
+ # Numeric and boolean fallbacks
1896
+ effective_timeout: int = (
1897
+ timeout if timeout is not None else int(_cfg.get("replay.timeout", 5000))
1898
+ )
1899
+ effective_save: bool = save or bool(_cfg.get("replay.auto_save", False))
1900
+ effective_no_diff: bool = no_diff or bool(_cfg.get("replay.diff_only", False))
1901
+ effective_otlp_export: bool = otlp_export or bool(_cfg.get("replay.otlp_export", False))
1902
+ effective_otlp_endpoint: str = otlp_endpoint or str(
1903
+ _cfg.get("replay.otlp_endpoint", "http://localhost:4317")
1904
+ )
1905
+ effective_otlp_service_name: str = otlp_service_name or str(
1906
+ _cfg.get("replay.otlp_service_name", "mcp-debugger")
1907
+ )
1908
+
1909
+ def format_payload(val: Any) -> str:
1910
+ if val is None:
1911
+ return "None"
1912
+ try:
1913
+ return json.dumps(val, indent=2)
1914
+ except Exception:
1915
+ return str(val)
1916
+
1917
+ def indent_text(text: str, spaces: int = 2) -> str:
1918
+ indent = " " * spaces
1919
+ return "\n".join(indent + line for line in text.splitlines())
1920
+
1921
+ async def _run() -> None:
1922
+ db = Database()
1923
+ try:
1924
+ try:
1925
+ await db.connect()
1926
+ except Exception as e:
1927
+ console.print(f"[red]Error connecting to database: {e}[/red]")
1928
+ sys.exit(1)
1929
+
1930
+ session = await db.get_session(session_id)
1931
+ if not session:
1932
+ console.print(f"[red]Error: Session #{session_id} not found.[/red]")
1933
+ sys.exit(1)
1934
+
1935
+ from mcp_debugger.replay.engine import ReplayEngine
1936
+
1937
+ engine = ReplayEngine(db)
1938
+
1939
+ # Set up progress bar if not in JSON mode and output is terminal
1940
+ progress_bar = None
1941
+ task_id = None
1942
+
1943
+ if not json_mode:
1944
+ from rich.progress import Progress, BarColumn, TextColumn, TimeElapsedColumn
1945
+
1946
+ progress_bar = Progress(
1947
+ TextColumn(f"Replaying session {session_id}..."),
1948
+ BarColumn(),
1949
+ TextColumn("{task.completed}/{task.total} ({task.percentage:>3.0f}%)"),
1950
+ TimeElapsedColumn(),
1951
+ console=console,
1952
+ )
1953
+ progress_bar.start()
1954
+ task_id = progress_bar.add_task("replaying", total=0)
1955
+
1956
+ def on_message_replayed(current: int, total: int) -> None:
1957
+ if progress_bar and task_id is not None:
1958
+ progress_bar.update(task_id, completed=current, total=total)
1959
+
1960
+ # Replay the messages
1961
+ replay_mode = "exact"
1962
+ message_filter = None
1963
+ if filter_method:
1964
+ replay_mode = "selective"
1965
+ message_filter = [filter_method]
1966
+
1967
+ result = await engine.replay(
1968
+ session_id=session_id,
1969
+ target_server_command=effective_server,
1970
+ timeout_ms=effective_timeout,
1971
+ replay_mode=replay_mode,
1972
+ message_filter=message_filter,
1973
+ persist=effective_save,
1974
+ max_messages=max_messages,
1975
+ on_message_replayed=on_message_replayed,
1976
+ )
1977
+
1978
+ if progress_bar:
1979
+ progress_bar.stop()
1980
+ finally:
1981
+ await db.close()
1982
+
1983
+ # Check for target server failed to start (command not found / invalid command)
1984
+ failed_to_start = False
1985
+ for msg in result.messages:
1986
+ if msg.error and "Failed to start server" in msg.error:
1987
+ failed_to_start = True
1988
+ break
1989
+
1990
+ if failed_to_start:
1991
+ # Print error and exit with code 2
1992
+ console.print(
1993
+ f"[red]Error: Target server failed to start: {result.messages[0].error}[/red]"
1994
+ )
1995
+ sys.exit(2)
1996
+
1997
+ # Check if server crashed during replay
1998
+ crashed_msg = None
1999
+ for msg in result.messages:
2000
+ if msg.error and (
2001
+ "terminated" in msg.error.lower() or "write error" in msg.error.lower()
2002
+ ):
2003
+ crashed_msg = msg
2004
+ break
2005
+
2006
+ if crashed_msg is not None:
2007
+ console.print(
2008
+ f"[red]Error: Server crashed during message #{crashed_msg.original_message_id}: {crashed_msg.error}[/red]"
2009
+ )
2010
+ sys.exit(2)
2011
+
2012
+ # Check if server timed out
2013
+ if result.timed_out > 0:
2014
+ # Print timeout details and exit with code 2
2015
+ timed_out_msgs = [m for m in result.messages if m.error and "Timeout" in m.error]
2016
+ if timed_out_msgs:
2017
+ console.print(
2018
+ f"[red]Error: Server timed out during message #{timed_out_msgs[0].original_message_id}: {timed_out_msgs[0].error}[/red]"
2019
+ )
2020
+ sys.exit(2)
2021
+
2022
+ # Setup redirection for output
2023
+ if output:
2024
+ capture_file = io.StringIO()
2025
+ run_console = Console(file=capture_file, force_terminal=True, color_system="truecolor")
2026
+ else:
2027
+ run_console = console
2028
+
2029
+ # Calculate counts
2030
+ successful_matches = sum(1 for m in result.messages if m.matches)
2031
+ mismatches = result.mismatched_responses
2032
+ timeouts = result.timed_out
2033
+ errors = result.failed_responses
2034
+ duration = (result.ended_at - result.started_at).total_seconds()
2035
+
2036
+ if json_mode:
2037
+ # Build and serialize JSON report
2038
+ json_report = {
2039
+ "session_id": session_id,
2040
+ "source_server_command": session["server_command"],
2041
+ "target_server_command": effective_server,
2042
+ "started_at": result.started_at.isoformat().replace("+00:00", "Z"),
2043
+ "ended_at": result.ended_at.isoformat().replace("+00:00", "Z"),
2044
+ "duration_seconds": round(duration, 2),
2045
+ "summary": {
2046
+ "total": result.total_messages_replayed,
2047
+ "matches": successful_matches,
2048
+ "mismatches": mismatches,
2049
+ "timeouts": timeouts,
2050
+ "errors": errors,
2051
+ },
2052
+ "messages": [
2053
+ {
2054
+ "original_message_id": m.original_message_id,
2055
+ "method": m.method,
2056
+ "matched": m.matches,
2057
+ "diff": [d.model_dump() for d in m.diff] if m.diff else None,
2058
+ }
2059
+ for m in result.messages
2060
+ ],
2061
+ }
2062
+ json_str = json.dumps(json_report, indent=2)
2063
+ if output:
2064
+ try:
2065
+ Path(output).write_text(json_str, encoding="utf-8")
2066
+ except Exception as e:
2067
+ console.print(f"[red]Error writing output to {output}: {e}[/red]")
2068
+ sys.exit(1)
2069
+ else:
2070
+ print(json_str)
2071
+ else:
2072
+ # Terminal Output (Default)
2073
+ summary_lines = [
2074
+ f"Replay of Session #{session_id}",
2075
+ f"Source server: {session['server_command']}",
2076
+ f"Target server: {effective_server}",
2077
+ f"Duration: {duration:.2f} seconds",
2078
+ "─" * 65,
2079
+ f"Total messages replayed: {result.total_messages_replayed}",
2080
+ f"[green]{EMOJI_CHECK} Successful matches: {successful_matches}[/green]",
2081
+ f"[red]{EMOJI_CROSS} Mismatches: {mismatches}[/red]"
2082
+ if mismatches
2083
+ else f"{EMOJI_CROSS} Mismatches: {mismatches}",
2084
+ f"[yellow]{EMOJI_TIMEOUT} Timeouts: {timeouts}[/yellow]"
2085
+ if timeouts
2086
+ else f"{EMOJI_TIMEOUT} Timeouts: {timeouts}",
2087
+ f"[red]{EMOJI_ERROR} Errors: {errors}[/red]" if errors else f"{EMOJI_ERROR} Errors: {errors}",
2088
+ ]
2089
+ summary_panel = Panel(
2090
+ "\n".join(summary_lines),
2091
+ title="Replay Summary",
2092
+ border_style="blue",
2093
+ )
2094
+ run_console.print(summary_panel)
2095
+
2096
+ # Print messages detailed reports
2097
+ for m in result.messages:
2098
+ if m.matches:
2099
+ if verbose:
2100
+ run_console.print(
2101
+ f"[green]✓[/green] Message #{m.original_message_id}: {m.method}"
2102
+ )
2103
+ else:
2104
+ run_console.print(
2105
+ f"\n[red]✗[/red] Message #{m.original_message_id}: {m.method} (client → server)"
2106
+ )
2107
+ if not effective_no_diff:
2108
+ # Show mismatch details
2109
+ if m.method == "tools/call" and m.request_sent:
2110
+ params = m.request_sent.get("params", {})
2111
+ if isinstance(params, dict):
2112
+ tool_name = params.get("name")
2113
+ tool_args = params.get("arguments")
2114
+ if tool_name:
2115
+ run_console.print(f"Tool: {tool_name}")
2116
+ if tool_args is not None:
2117
+ run_console.print(f"Arguments: {json.dumps(tool_args)}")
2118
+
2119
+ run_console.print("\nOriginal response:")
2120
+ run_console.print(indent_text(format_payload(m.original_response), 2))
2121
+ run_console.print("\nReplayed response:")
2122
+ run_console.print(indent_text(format_payload(m.replayed_response), 2))
2123
+
2124
+ if m.diff_text:
2125
+ run_console.print("\nDifferences:")
2126
+ run_console.print(indent_text(m.diff_text, 2))
2127
+ run_console.print()
2128
+
2129
+ if effective_no_diff:
2130
+ mismatched_ids = [m.original_message_id for m in result.messages if not m.matches]
2131
+ if mismatched_ids:
2132
+ run_console.print(f"\nMismatched Message IDs: {mismatched_ids}")
2133
+
2134
+ if effective_save and result.replay_id is not None and result.replay_id != -1:
2135
+ run_console.print(
2136
+ f"\nReplay saved as replay ID {result.replay_id}. Use 'mcp-debugger replay show {result.replay_id}' to view later."
2137
+ )
2138
+
2139
+ if output:
2140
+ try:
2141
+ Path(output).write_text(capture_file.getvalue(), encoding="utf-8")
2142
+ except Exception as e:
2143
+ console.print(f"[red]Error writing output to {output}: {e}[/red]")
2144
+ sys.exit(1)
2145
+
2146
+ # Exit codes:
2147
+ # 0 if all responses match
2148
+ # 1 if any mismatch (i.e. mismatches > 0)
2149
+
2150
+ # --- OTLP export (optional, non-blocking) ---
2151
+ if effective_otlp_export:
2152
+ try:
2153
+ from mcp_debugger.exporters.otlp_replay_exporter import OTLPReplayExporter
2154
+
2155
+ exporter_otlp = OTLPReplayExporter(
2156
+ endpoint=effective_otlp_endpoint,
2157
+ insecure=otlp_insecure,
2158
+ service_name=effective_otlp_service_name,
2159
+ )
2160
+ span_count = exporter_otlp.export(result)
2161
+ console.print(
2162
+ f"[dim]OTLP: exported {span_count} spans to {effective_otlp_endpoint}[/dim]"
2163
+ )
2164
+ except ImportError as ie:
2165
+ console.print(f"[yellow]Warning: {ie}[/yellow]")
2166
+ except Exception as oe:
2167
+ console.print(f"[yellow]Warning: OTLP export failed: {oe}[/yellow]")
2168
+
2169
+ if mismatches > 0:
2170
+ sys.exit(1)
2171
+ else:
2172
+ sys.exit(0)
2173
+
2174
+ try:
2175
+ asyncio.run(_run())
2176
+ except KeyboardInterrupt:
2177
+ sys.exit(0)
2178
+
2179
+
2180
+ def main() -> None:
2181
+ app()
2182
+
2183
+
2184
+ if __name__ == "__main__":
2185
+ main()