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.
@@ -0,0 +1,5 @@
1
+ """MCP Debugger package."""
2
+
3
+ from .version import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,443 @@
1
+ """Analytics engine for calculating session statistics and comparisons."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from collections import defaultdict
6
+ from typing import Dict, List, Optional, Tuple
7
+ from pydantic import BaseModel, Field
8
+
9
+ from mcp_debugger.storage.database import Database
10
+
11
+ # Unicode block elements for sparkline: 8 levels
12
+ BLOCKS = [" ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
13
+
14
+
15
+ class ToolMetric(BaseModel):
16
+ """Calculated metrics for a single tool."""
17
+
18
+ name: str
19
+ calls: int = 0
20
+ avg_latency_ms: Optional[float] = None
21
+ error_rate: float = 0.0
22
+ errors_count: int = 0
23
+
24
+
25
+ class SessionStats(BaseModel):
26
+ """Container for computed session statistics."""
27
+
28
+ session_id: int
29
+ friendly_name: Optional[str] = None
30
+ server_command: str
31
+ started_at: Optional[str] = None
32
+ ended_at: Optional[str] = None
33
+ status: str
34
+ duration_seconds: Optional[int] = None
35
+ total_messages: int = 0
36
+ client_to_server_count: int = 0
37
+ server_to_client_count: int = 0
38
+ top_tools: List[ToolMetric] = Field(default_factory=list)
39
+ errors_by_category: Dict[str, int] = Field(default_factory=dict)
40
+ latency_min: Optional[float] = None
41
+ latency_max: Optional[float] = None
42
+ latency_avg: Optional[float] = None
43
+ latency_trend: List[float] = Field(default_factory=list)
44
+ method_distribution: Dict[str, int] = Field(default_factory=dict)
45
+ error_trend: List[int] = Field(default_factory=list)
46
+
47
+
48
+ class ToolChange(BaseModel):
49
+ """Calculated deltas for a specific tool between two sessions."""
50
+
51
+ name: str
52
+ calls_a: int = 0
53
+ calls_b: int = 0
54
+ change_pct: Optional[float] = None # None if division by zero
55
+ change_str: str # e.g. "+15%", "-75%", "removed", "new"
56
+ avg_latency_a: Optional[float] = None
57
+ avg_latency_b: Optional[float] = None
58
+ avg_latency_change_pct: Optional[float] = None
59
+
60
+
61
+ class ComparisonStats(BaseModel):
62
+ """Container for comparative session statistics."""
63
+
64
+ session_id_a: int
65
+ session_id_b: int
66
+ duration_a: Optional[int] = None
67
+ duration_b: Optional[int] = None
68
+ duration_change_pct: Optional[float] = None
69
+ duration_change_str: str = ""
70
+ messages_a: int = 0
71
+ messages_b: int = 0
72
+ messages_change_abs: int = 0
73
+ tool_changes: List[ToolChange] = Field(default_factory=list)
74
+ new_tools: List[str] = Field(default_factory=list)
75
+ removed_tools: List[str] = Field(default_factory=list)
76
+ errors_a: int = 0
77
+ errors_b: int = 0
78
+ error_rate_a: float = 0.0
79
+ error_rate_b: float = 0.0
80
+ error_rate_change_str: str = ""
81
+
82
+
83
+ def generate_sparkline(values: List[float], width: int = 30) -> str:
84
+ """Generate a horizontal Unicode sparkline representing values over time.
85
+
86
+ If length of values is greater than target width, downsamples by averaging.
87
+ """
88
+ if not values:
89
+ return ""
90
+
91
+ # Downsample if needed
92
+ if len(values) > width:
93
+ chunk_size = len(values) / width
94
+ sampled = []
95
+ for i in range(width):
96
+ start = int(i * chunk_size)
97
+ end = int((i + 1) * chunk_size)
98
+ if start == end:
99
+ end = start + 1
100
+ chunk = values[start:end]
101
+ if chunk:
102
+ sampled.append(sum(chunk) / len(chunk))
103
+ values = sampled
104
+
105
+ min_v = min(values)
106
+ max_v = max(values)
107
+
108
+ if max_v == min_v:
109
+ # If all values are 0 (common for error count), show empty/lowest bar
110
+ if max_v == 0:
111
+ return " " * len(values)
112
+ # Otherwise show middle bar
113
+ return "▄" * len(values)
114
+
115
+ sparkline = []
116
+ for val in values:
117
+ # Scale to index 0-7
118
+ idx = int(((val - min_v) / (max_v - min_v)) * 7)
119
+ idx = max(0, min(7, idx))
120
+ sparkline.append(BLOCKS[idx])
121
+
122
+ return "".join(sparkline)
123
+
124
+
125
+ def generate_bar_chart(
126
+ counts: Dict[str, int], max_width: int = 30
127
+ ) -> List[Tuple[str, int, float, str]]:
128
+ """Generate bar chart segments (label, count, percentage, bar_string) sorted by count descending."""
129
+ if not counts:
130
+ return []
131
+
132
+ total = sum(counts.values())
133
+ sorted_items = sorted(counts.items(), key=lambda x: x[1], reverse=True)
134
+
135
+ results = []
136
+ for label, count in sorted_items:
137
+ pct = count / total if total > 0 else 0.0
138
+ filled = int(pct * max_width)
139
+ empty = max_width - filled
140
+ bar_str = "█" * filled + "░" * empty
141
+ results.append((label, count, pct, bar_str))
142
+
143
+ return results
144
+
145
+
146
+ async def aggregate_session_stats(db: Database, session_id: int) -> SessionStats:
147
+ """Query database and calculate aggregates for a single session."""
148
+ session = await db.get_session(session_id)
149
+ if not session:
150
+ raise ValueError(f"Session {session_id} not found")
151
+
152
+ # Fetch messages and errors
153
+ messages = await db.get_messages(session_id)
154
+ errors = await db.get_errors(session_id)
155
+
156
+ # Compute client vs server message counts, and method distribution
157
+ c2s_count = 0
158
+ s2c_count = 0
159
+ method_dist: Dict[str, int] = defaultdict(int)
160
+
161
+ # We also keep track of error flags chronologically for the error trend sparkline
162
+ chrono_errors: List[int] = []
163
+
164
+ for msg in messages:
165
+ direction = msg.get("direction")
166
+ if direction == "client_to_server":
167
+ c2s_count += 1
168
+ elif direction == "server_to_client":
169
+ s2c_count += 1
170
+
171
+ method = msg.get("method")
172
+ if method:
173
+ method_dist[method] += 1
174
+
175
+ # Check for error status
176
+ is_err = False
177
+ if msg.get("error") is not None:
178
+ is_err = True
179
+ elif msg.get("message_type") == "response" and msg.get("result") is not None:
180
+ try:
181
+ res = json.loads(msg["result"])
182
+ if isinstance(res, dict) and res.get("isError") is True:
183
+ is_err = True
184
+ except Exception:
185
+ pass
186
+
187
+ # We only look at response messages (server_to_client) or requests that errored for trend
188
+ # For simplicity, track error flags for all server_to_client responses chronologically
189
+ if direction == "server_to_client":
190
+ chrono_errors.append(1 if is_err else 0)
191
+
192
+ # Compute latency list
193
+ latency_list = [msg["latency_ms"] for msg in messages if msg.get("latency_ms") is not None]
194
+
195
+ # Compute tool usage metrics
196
+ # Query all request-response pairs for tools/call
197
+ conn = await db._get_conn()
198
+ tool_calls: Dict[str, List[Tuple[Optional[float], bool]]] = defaultdict(list)
199
+ try:
200
+ async with conn.execute(
201
+ """
202
+ SELECT
203
+ req.params as req_params,
204
+ resp.latency_ms as latency_ms,
205
+ resp.result as resp_result,
206
+ resp.error as resp_error
207
+ FROM messages req
208
+ LEFT JOIN messages resp ON req.session_id = resp.session_id
209
+ AND req.message_id = resp.message_id
210
+ AND resp.message_type = 'response'
211
+ WHERE req.session_id = ?
212
+ AND req.method = 'tools/call'
213
+ AND req.message_type = 'request'
214
+ """,
215
+ (session_id,),
216
+ ) as cursor:
217
+ rows = await cursor.fetchall()
218
+ for row in rows:
219
+ req_params_str = row[0]
220
+ latency_ms = row[1]
221
+ resp_result_str = row[2]
222
+ resp_error_str = row[3]
223
+
224
+ tool_name = "unknown"
225
+ if req_params_str:
226
+ try:
227
+ params = json.loads(req_params_str)
228
+ if isinstance(params, dict) and "name" in params:
229
+ tool_name = params["name"]
230
+ except Exception:
231
+ pass
232
+
233
+ is_error = False
234
+ if resp_error_str:
235
+ is_error = True
236
+ elif resp_result_str:
237
+ try:
238
+ res = json.loads(resp_result_str)
239
+ if isinstance(res, dict) and res.get("isError") is True:
240
+ is_error = True
241
+ except Exception:
242
+ pass
243
+
244
+ tool_calls[tool_name].append((latency_ms, is_error))
245
+ except Exception:
246
+ # Fallback or log warning
247
+ pass
248
+
249
+ top_tools: List[ToolMetric] = []
250
+ for tname, calls in tool_calls.items():
251
+ total_calls = len(calls)
252
+ errors_count = sum(1 for c in calls if c[1])
253
+ error_rate = (errors_count / total_calls) if total_calls > 0 else 0.0
254
+
255
+ latencies = [c[0] for c in calls if c[0] is not None]
256
+ avg_lat = (sum(latencies) / len(latencies)) if latencies else None
257
+
258
+ top_tools.append(
259
+ ToolMetric(
260
+ name=tname,
261
+ calls=total_calls,
262
+ avg_latency_ms=avg_lat,
263
+ error_rate=error_rate,
264
+ errors_count=errors_count,
265
+ )
266
+ )
267
+ # Sort tools by call count descending
268
+ top_tools.sort(key=lambda x: x.calls, reverse=True)
269
+
270
+ # Compute errors by category
271
+ errors_by_cat: Dict[str, int] = defaultdict(int)
272
+ for err in errors:
273
+ cat = err.get("error_type") or "unknown"
274
+ errors_by_cat[cat] += 1
275
+
276
+ # Latency aggregates
277
+ lat_min = min(latency_list) if latency_list else None
278
+ lat_max = max(latency_list) if latency_list else None
279
+ lat_avg = (sum(latency_list) / len(latency_list)) if latency_list else None
280
+
281
+ # Parse timestamps for duration if needed
282
+ duration_sec = session.get("duration_seconds")
283
+ if duration_sec is None and session.get("started_at"):
284
+ try:
285
+ started = datetime.strptime(session["started_at"], "%Y-%m-%d %H:%M:%S")
286
+ ended = (
287
+ datetime.strptime(session["ended_at"], "%Y-%m-%d %H:%M:%S")
288
+ if session.get("ended_at")
289
+ else datetime.now()
290
+ )
291
+ duration_sec = int((ended - started).total_seconds())
292
+ except Exception:
293
+ pass
294
+
295
+ return SessionStats(
296
+ session_id=session_id,
297
+ friendly_name=session.get("friendly_name"),
298
+ server_command=session.get("server_command") or "",
299
+ started_at=session.get("started_at"),
300
+ ended_at=session.get("ended_at"),
301
+ status=session.get("status") or "unknown",
302
+ duration_seconds=duration_sec,
303
+ total_messages=len(messages),
304
+ client_to_server_count=c2s_count,
305
+ server_to_client_count=s2c_count,
306
+ top_tools=top_tools,
307
+ errors_by_category=dict(errors_by_cat),
308
+ latency_min=lat_min,
309
+ latency_max=lat_max,
310
+ latency_avg=lat_avg,
311
+ latency_trend=latency_list,
312
+ method_distribution=dict(method_dist),
313
+ error_trend=chrono_errors,
314
+ )
315
+
316
+
317
+ def compare_sessions_stats(stats_a: SessionStats, stats_b: SessionStats) -> ComparisonStats:
318
+ """Compare two SessionStats aggregates and calculate deltas."""
319
+ dur_a = stats_a.duration_seconds
320
+ dur_b = stats_b.duration_seconds
321
+ dur_pct = None
322
+ dur_change_str = "—"
323
+ if dur_a is not None and dur_b is not None:
324
+ if dur_a > 0:
325
+ dur_pct = ((dur_b - dur_a) / dur_a) * 100
326
+ if dur_b < dur_a:
327
+ dur_change_str = f"↓ {abs(dur_pct):.0f}% faster"
328
+ elif dur_b > dur_a:
329
+ dur_change_str = f"↑ {abs(dur_pct):.0f}% slower"
330
+ else:
331
+ dur_change_str = "no change"
332
+ else:
333
+ dur_change_str = "—"
334
+
335
+ msg_a = stats_a.total_messages
336
+ msg_b = stats_b.total_messages
337
+ msg_diff = msg_b - msg_a
338
+
339
+ # Maps tool names to stats
340
+ tools_a_map = {t.name: t for t in stats_a.top_tools}
341
+ tools_b_map = {t.name: t for t in stats_b.top_tools}
342
+
343
+ all_tool_names = set(tools_a_map.keys()) | set(tools_b_map.keys())
344
+ tool_changes: List[ToolChange] = []
345
+ new_tools: List[str] = []
346
+ removed_tools: List[str] = []
347
+
348
+ for name in all_tool_names:
349
+ ta = tools_a_map.get(name)
350
+ tb = tools_b_map.get(name)
351
+
352
+ if ta and not tb:
353
+ removed_tools.append(name)
354
+ tool_changes.append(
355
+ ToolChange(
356
+ name=name,
357
+ calls_a=ta.calls,
358
+ calls_b=0,
359
+ change_pct=None,
360
+ change_str="✗ removed",
361
+ avg_latency_a=ta.avg_latency_ms,
362
+ avg_latency_b=None,
363
+ avg_latency_change_pct=None,
364
+ )
365
+ )
366
+ elif tb and not ta:
367
+ new_tools.append(name)
368
+ tool_changes.append(
369
+ ToolChange(
370
+ name=name,
371
+ calls_a=0,
372
+ calls_b=tb.calls,
373
+ change_pct=None,
374
+ change_str="★ new",
375
+ avg_latency_a=None,
376
+ avg_latency_b=tb.avg_latency_ms,
377
+ avg_latency_change_pct=None,
378
+ )
379
+ )
380
+ elif ta and tb:
381
+ diff = tb.calls - ta.calls
382
+ pct = ((tb.calls - ta.calls) / ta.calls) * 100 if ta.calls > 0 else 0.0
383
+ if diff > 0:
384
+ change_str = f"↑ +{pct:.0f}%"
385
+ elif diff < 0:
386
+ change_str = f"↓ {pct:.0f}%"
387
+ else:
388
+ change_str = "no change"
389
+
390
+ lat_change_pct = None
391
+ if (
392
+ ta.avg_latency_ms is not None
393
+ and tb.avg_latency_ms is not None
394
+ and ta.avg_latency_ms > 0
395
+ ):
396
+ lat_change_pct = ((tb.avg_latency_ms - ta.avg_latency_ms) / ta.avg_latency_ms) * 100
397
+
398
+ tool_changes.append(
399
+ ToolChange(
400
+ name=name,
401
+ calls_a=ta.calls,
402
+ calls_b=tb.calls,
403
+ change_pct=pct,
404
+ change_str=change_str,
405
+ avg_latency_a=ta.avg_latency_ms,
406
+ avg_latency_b=tb.avg_latency_ms,
407
+ avg_latency_change_pct=lat_change_pct,
408
+ )
409
+ )
410
+
411
+ # Errors calculation
412
+ err_a = sum(stats_a.errors_by_category.values())
413
+ err_b = sum(stats_b.errors_by_category.values())
414
+
415
+ err_rate_a = (err_a / msg_a) * 100 if msg_a > 0 else 0.0
416
+ err_rate_b = (err_b / msg_b) * 100 if msg_b > 0 else 0.0
417
+
418
+ if err_rate_b < err_rate_a:
419
+ err_rate_change_str = "↓ improvement"
420
+ elif err_rate_b > err_rate_a:
421
+ err_rate_change_str = "↑ regression"
422
+ else:
423
+ err_rate_change_str = "no change"
424
+
425
+ return ComparisonStats(
426
+ session_id_a=stats_a.session_id,
427
+ session_id_b=stats_b.session_id,
428
+ duration_a=dur_a,
429
+ duration_b=dur_b,
430
+ duration_change_pct=dur_pct,
431
+ duration_change_str=dur_change_str,
432
+ messages_a=msg_a,
433
+ messages_b=msg_b,
434
+ messages_change_abs=msg_diff,
435
+ tool_changes=tool_changes,
436
+ new_tools=new_tools,
437
+ removed_tools=removed_tools,
438
+ errors_a=err_a,
439
+ errors_b=err_b,
440
+ error_rate_a=err_rate_a,
441
+ error_rate_b=err_rate_b,
442
+ error_rate_change_str=err_rate_change_str,
443
+ )