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/__init__.py +5 -0
- mcp_debugger/analytics.py +443 -0
- mcp_debugger/cli.py +2185 -0
- mcp_debugger/config.py +377 -0
- mcp_debugger/display/__init__.py +0 -0
- mcp_debugger/exporters/__init__.py +6 -0
- mcp_debugger/exporters/json_exporter.py +178 -0
- mcp_debugger/exporters/markdown_exporter.py +196 -0
- mcp_debugger/exporters/otlp_exporter.py +206 -0
- mcp_debugger/exporters/otlp_replay_exporter.py +221 -0
- mcp_debugger/protocol/__init__.py +0 -0
- mcp_debugger/protocol/error_classifier.py +108 -0
- mcp_debugger/protocol/schemas.py +92 -0
- mcp_debugger/protocol/validator.py +471 -0
- mcp_debugger/proxy/__init__.py +0 -0
- mcp_debugger/proxy/stdio_proxy.py +408 -0
- mcp_debugger/py.typed +1 -0
- mcp_debugger/replay/__init__.py +14 -0
- mcp_debugger/replay/diff.py +168 -0
- mcp_debugger/replay/engine.py +446 -0
- mcp_debugger/storage/__init__.py +0 -0
- mcp_debugger/storage/database.py +959 -0
- mcp_debugger/validate_live.py +250 -0
- mcp_debugger/version.py +3 -0
- mcp_debugger-0.1.0.dist-info/METADATA +207 -0
- mcp_debugger-0.1.0.dist-info/RECORD +29 -0
- mcp_debugger-0.1.0.dist-info/WHEEL +4 -0
- mcp_debugger-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_debugger-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|