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/config.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Configuration management for mcp-debugger.
|
|
2
|
+
|
|
3
|
+
Loads and saves a TOML config file at ``~/.mcp-debugger/config.toml``
|
|
4
|
+
(or ``%APPDATA%\\mcp-debugger\\config.toml`` on Windows).
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from mcp_debugger.config import Config
|
|
9
|
+
|
|
10
|
+
cfg = Config()
|
|
11
|
+
cfg.load() # idempotent, safe to call repeatedly
|
|
12
|
+
timeout = cfg.get("replay.timeout", 5000)
|
|
13
|
+
cfg.set("replay.timeout", 10000)
|
|
14
|
+
|
|
15
|
+
Keys use dot-notation: ``"replay.timeout"``, ``"aliases.fs"``, etc.
|
|
16
|
+
|
|
17
|
+
Precedence (handled by the caller, not this module):
|
|
18
|
+
CLI flag > Config file > Hardcoded default
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Dict, Optional
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("mcp_debugger.config")
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Default configuration values
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
DEFAULT_CONFIG: Dict[str, Any] = {
|
|
36
|
+
"general": {
|
|
37
|
+
"default_output": "rich",
|
|
38
|
+
"color": True,
|
|
39
|
+
},
|
|
40
|
+
"proxy": {
|
|
41
|
+
"timeout": 5000,
|
|
42
|
+
"verbose": False,
|
|
43
|
+
"default_session_name": "mcp-session",
|
|
44
|
+
},
|
|
45
|
+
"replay": {
|
|
46
|
+
"timeout": 5000,
|
|
47
|
+
"default_server": "",
|
|
48
|
+
"auto_save": False,
|
|
49
|
+
"diff_only": False,
|
|
50
|
+
"otlp_endpoint": "http://localhost:4317",
|
|
51
|
+
"otlp_service_name": "mcp-debugger",
|
|
52
|
+
"otlp_export": False,
|
|
53
|
+
},
|
|
54
|
+
"export": {
|
|
55
|
+
"default_format": "json",
|
|
56
|
+
"pretty_json": True,
|
|
57
|
+
},
|
|
58
|
+
"validate": {
|
|
59
|
+
"strict": False,
|
|
60
|
+
"auto_validate": False,
|
|
61
|
+
},
|
|
62
|
+
"doctor": {
|
|
63
|
+
"check_optional": True,
|
|
64
|
+
"node_path": "",
|
|
65
|
+
},
|
|
66
|
+
"aliases": {},
|
|
67
|
+
"profiles": {},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Config file location
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _config_dir() -> Path:
|
|
76
|
+
"""Return the platform-appropriate config directory."""
|
|
77
|
+
if sys.platform == "win32":
|
|
78
|
+
appdata = os.environ.get("APPDATA") or Path.home()
|
|
79
|
+
return Path(appdata) / "mcp-debugger"
|
|
80
|
+
return Path.home() / ".mcp-debugger"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def default_config_path() -> Path:
|
|
84
|
+
"""Return the default path to config.toml."""
|
|
85
|
+
return _config_dir() / "config.toml"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# TOML helpers
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _loads_toml(text: str) -> Dict[str, Any]:
|
|
94
|
+
"""Parse TOML text using the stdlib tomllib (Python 3.11+)."""
|
|
95
|
+
if sys.version_info >= (3, 11):
|
|
96
|
+
import tomllib # pragma: no cover
|
|
97
|
+
|
|
98
|
+
return tomllib.loads(text)
|
|
99
|
+
else: # pragma: no cover
|
|
100
|
+
try:
|
|
101
|
+
import tomllib # type: ignore[no-redef]
|
|
102
|
+
except ImportError:
|
|
103
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
104
|
+
return tomllib.loads(text) # type: ignore[no-any-return]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _dumps_toml(data: Dict[str, Any]) -> str:
|
|
108
|
+
"""Serialize a two-level dict to TOML text.
|
|
109
|
+
|
|
110
|
+
Supports:
|
|
111
|
+
* Flat sections: ``[section]`` with string/int/float/bool scalar values.
|
|
112
|
+
* One nested level for ``[aliases]`` and ``[profiles]``.
|
|
113
|
+
* Profiles have their own sub-table: ``[profiles.name]``.
|
|
114
|
+
|
|
115
|
+
This is intentionally minimal – it only handles the structures that appear
|
|
116
|
+
in the default config.
|
|
117
|
+
"""
|
|
118
|
+
lines: list[str] = [
|
|
119
|
+
"# mcp-debugger configuration",
|
|
120
|
+
"# Edit this file or use `mcp-debugger config set <key> <value>`",
|
|
121
|
+
"",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
for section, value in data.items():
|
|
125
|
+
if not isinstance(value, dict):
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
# aliases and profiles get special handling (one extra level)
|
|
129
|
+
if section == "profiles":
|
|
130
|
+
lines.append(f"[{section}]")
|
|
131
|
+
for profile_name, profile_val in value.items():
|
|
132
|
+
if isinstance(profile_val, dict):
|
|
133
|
+
lines.append(f"[{section}.{profile_name}]")
|
|
134
|
+
for k, v in profile_val.items():
|
|
135
|
+
lines.append(f"{k} = {_toml_value(v)}")
|
|
136
|
+
lines.append("")
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
lines.append(f"[{section}]")
|
|
140
|
+
for k, v in value.items():
|
|
141
|
+
lines.append(f"{k} = {_toml_value(v)}")
|
|
142
|
+
lines.append("")
|
|
143
|
+
|
|
144
|
+
return "\n".join(lines)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _toml_value(v: Any) -> str:
|
|
148
|
+
"""Serialize a scalar value to TOML format."""
|
|
149
|
+
if isinstance(v, bool):
|
|
150
|
+
return "true" if v else "false"
|
|
151
|
+
if isinstance(v, (int, float)):
|
|
152
|
+
return str(v)
|
|
153
|
+
if isinstance(v, str):
|
|
154
|
+
# Escape backslashes and double-quotes
|
|
155
|
+
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
|
156
|
+
return f'"{escaped}"'
|
|
157
|
+
return f'"{v}"'
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# Config class
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Config:
|
|
166
|
+
"""Read/write the mcp-debugger configuration file.
|
|
167
|
+
|
|
168
|
+
Attributes:
|
|
169
|
+
path: Absolute path to the config file. Defaults to the
|
|
170
|
+
platform-standard location but can be overridden (e.g. in tests).
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, path: Optional[Path] = None) -> None:
|
|
174
|
+
self.path: Path = path or default_config_path()
|
|
175
|
+
self._data: Dict[str, Any] = {}
|
|
176
|
+
self._loaded: bool = False
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Public API
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def load(self) -> None:
|
|
183
|
+
"""Load (or reload) the config from disk.
|
|
184
|
+
|
|
185
|
+
If the file does not exist, uses :data:`DEFAULT_CONFIG`.
|
|
186
|
+
If the file is invalid TOML, logs a warning and uses defaults.
|
|
187
|
+
Idempotent – safe to call multiple times.
|
|
188
|
+
"""
|
|
189
|
+
import copy
|
|
190
|
+
|
|
191
|
+
self._data = copy.deepcopy(DEFAULT_CONFIG)
|
|
192
|
+
|
|
193
|
+
if not self.path.exists():
|
|
194
|
+
self._loaded = True
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
text = self.path.read_text(encoding="utf-8")
|
|
199
|
+
parsed = _loads_toml(text)
|
|
200
|
+
# Deep-merge parsed on top of defaults so missing keys still have values
|
|
201
|
+
_deep_merge(self._data, parsed)
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
logger.warning("Config file %s is invalid (%s). Using defaults.", self.path, exc)
|
|
204
|
+
|
|
205
|
+
self._loaded = True
|
|
206
|
+
|
|
207
|
+
def save(self) -> None:
|
|
208
|
+
"""Write the current in-memory config to disk.
|
|
209
|
+
|
|
210
|
+
Creates parent directories if they don't exist.
|
|
211
|
+
Sets file permissions to 0o600.
|
|
212
|
+
"""
|
|
213
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
text = _dumps_toml(self._data)
|
|
215
|
+
self.path.write_text(text, encoding="utf-8")
|
|
216
|
+
try:
|
|
217
|
+
os.chmod(self.path, 0o600)
|
|
218
|
+
except OSError:
|
|
219
|
+
pass # Windows may not support this
|
|
220
|
+
|
|
221
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
222
|
+
"""Get a config value using dot-notation.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
key: Dot-separated path, e.g. ``"replay.timeout"``.
|
|
226
|
+
default: Value to return if the key is not found.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
The config value or *default*.
|
|
230
|
+
"""
|
|
231
|
+
if not self._loaded:
|
|
232
|
+
self.load()
|
|
233
|
+
parts = key.split(".")
|
|
234
|
+
node: Any = self._data
|
|
235
|
+
for part in parts:
|
|
236
|
+
if not isinstance(node, dict) or part not in node:
|
|
237
|
+
return default
|
|
238
|
+
node = node[part]
|
|
239
|
+
return node
|
|
240
|
+
|
|
241
|
+
def set(self, key: str, value: Any) -> None:
|
|
242
|
+
"""Set a config value using dot-notation and persist to disk.
|
|
243
|
+
|
|
244
|
+
Creates intermediate sections as needed.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
key: Dot-separated path, e.g. ``"replay.timeout"``.
|
|
248
|
+
value: The value to store.
|
|
249
|
+
"""
|
|
250
|
+
if not self._loaded:
|
|
251
|
+
self.load()
|
|
252
|
+
parts = key.split(".")
|
|
253
|
+
node: Dict[str, Any] = self._data
|
|
254
|
+
for part in parts[:-1]:
|
|
255
|
+
if part not in node or not isinstance(node[part], dict):
|
|
256
|
+
node[part] = {}
|
|
257
|
+
node = node[part]
|
|
258
|
+
# Coerce string numbers/booleans to their native types
|
|
259
|
+
node[parts[-1]] = _coerce(value)
|
|
260
|
+
self.save()
|
|
261
|
+
|
|
262
|
+
def unset(self, key: str) -> bool:
|
|
263
|
+
"""Remove a config key, reverting it to the default.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
key: Dot-separated path.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
``True`` if the key was found and removed, ``False`` otherwise.
|
|
270
|
+
"""
|
|
271
|
+
if not self._loaded:
|
|
272
|
+
self.load()
|
|
273
|
+
parts = key.split(".")
|
|
274
|
+
node: Dict[str, Any] = self._data
|
|
275
|
+
for part in parts[:-1]:
|
|
276
|
+
if not isinstance(node, dict) or part not in node:
|
|
277
|
+
return False
|
|
278
|
+
node = node[part]
|
|
279
|
+
if parts[-1] in node:
|
|
280
|
+
del node[parts[-1]]
|
|
281
|
+
self.save()
|
|
282
|
+
return True
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
def all(self) -> Dict[str, Any]:
|
|
286
|
+
"""Return a copy of the full in-memory config dict."""
|
|
287
|
+
if not self._loaded:
|
|
288
|
+
self.load()
|
|
289
|
+
import copy
|
|
290
|
+
|
|
291
|
+
return copy.deepcopy(self._data)
|
|
292
|
+
|
|
293
|
+
def reset(self) -> None:
|
|
294
|
+
"""Reset the config to defaults and persist."""
|
|
295
|
+
import copy
|
|
296
|
+
|
|
297
|
+
self._data = copy.deepcopy(DEFAULT_CONFIG)
|
|
298
|
+
self._loaded = True
|
|
299
|
+
self.save()
|
|
300
|
+
|
|
301
|
+
def resolve_alias(self, alias: str) -> Optional[str]:
|
|
302
|
+
"""Look up *alias* in ``[aliases]`` and return the server command.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
alias: Short alias name (e.g. ``"fs"``).
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
The server command string, or ``None`` if the alias is not found.
|
|
309
|
+
"""
|
|
310
|
+
if not self._loaded:
|
|
311
|
+
self.load()
|
|
312
|
+
aliases = self._data.get("aliases", {})
|
|
313
|
+
if not isinstance(aliases, dict):
|
|
314
|
+
return None
|
|
315
|
+
result = aliases.get(alias)
|
|
316
|
+
return str(result) if result is not None else None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# Helpers
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> None:
|
|
325
|
+
"""Recursively merge *override* into *base* in-place."""
|
|
326
|
+
for k, v in override.items():
|
|
327
|
+
if k in base and isinstance(base[k], dict) and isinstance(v, dict):
|
|
328
|
+
_deep_merge(base[k], v)
|
|
329
|
+
else:
|
|
330
|
+
base[k] = v
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _coerce(value: Any) -> Any:
|
|
334
|
+
"""Coerce CLI string inputs to native Python types when possible."""
|
|
335
|
+
if not isinstance(value, str):
|
|
336
|
+
return value
|
|
337
|
+
if value.lower() == "true":
|
|
338
|
+
return True
|
|
339
|
+
if value.lower() == "false":
|
|
340
|
+
return False
|
|
341
|
+
try:
|
|
342
|
+
return int(value)
|
|
343
|
+
except ValueError:
|
|
344
|
+
pass
|
|
345
|
+
try:
|
|
346
|
+
return float(value)
|
|
347
|
+
except ValueError:
|
|
348
|
+
pass
|
|
349
|
+
return value
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# Module-level singleton (lazy, one per process)
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
_GLOBAL_CONFIG: Optional[Config] = None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def get_config(path: Optional[Path] = None) -> Config:
|
|
360
|
+
"""Return the process-wide :class:`Config` singleton.
|
|
361
|
+
|
|
362
|
+
The first call initialises and loads the config. Subsequent calls
|
|
363
|
+
return the cached instance (unless *path* is specified, in which case
|
|
364
|
+
a new instance is created).
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
path: Override config path (mostly useful in tests).
|
|
368
|
+
"""
|
|
369
|
+
global _GLOBAL_CONFIG
|
|
370
|
+
if path is not None:
|
|
371
|
+
cfg = Config(path=path)
|
|
372
|
+
cfg.load()
|
|
373
|
+
return cfg
|
|
374
|
+
if _GLOBAL_CONFIG is None:
|
|
375
|
+
_GLOBAL_CONFIG = Config()
|
|
376
|
+
_GLOBAL_CONFIG.load()
|
|
377
|
+
return _GLOBAL_CONFIG
|
|
File without changes
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""JSON exporter – serialises a full session into a structured JSON document."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, IO, List, Optional
|
|
6
|
+
|
|
7
|
+
from mcp_debugger.analytics import SessionStats
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _iso(ts_ms: Optional[float]) -> Optional[str]:
|
|
11
|
+
"""Convert a float millisecond timestamp to an ISO-8601 UTC string."""
|
|
12
|
+
if ts_ms is None:
|
|
13
|
+
return None
|
|
14
|
+
return datetime.fromtimestamp(ts_ms / 1000.0, tz=timezone.utc).isoformat()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JSONExporter:
|
|
18
|
+
"""Convert session data into a structured JSON document.
|
|
19
|
+
|
|
20
|
+
The exporter is intentionally decoupled from the database; all data is
|
|
21
|
+
passed in as plain Python objects so the class is easy to unit-test.
|
|
22
|
+
|
|
23
|
+
Output shape::
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
"session": { ... },
|
|
27
|
+
"messages": [ ... ],
|
|
28
|
+
"tools": [ ... ],
|
|
29
|
+
"errors": [ ... ],
|
|
30
|
+
"stats": { ... }
|
|
31
|
+
}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, pretty: bool = False, include_raw: bool = False) -> None:
|
|
35
|
+
"""Initialise the exporter.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
pretty: If ``True`` use ``indent=2`` when serialising JSON.
|
|
39
|
+
include_raw: Unused here (kept for interface symmetry with the
|
|
40
|
+
Markdown exporter). All message fields are always included.
|
|
41
|
+
"""
|
|
42
|
+
self.pretty = pretty
|
|
43
|
+
self.include_raw = include_raw
|
|
44
|
+
self._indent: Optional[int] = 2 if pretty else None
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
# Public API
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def export(
|
|
51
|
+
self,
|
|
52
|
+
session: Dict[str, Any],
|
|
53
|
+
messages: List[Dict[str, Any]],
|
|
54
|
+
tools: List[Dict[str, Any]],
|
|
55
|
+
errors: List[Dict[str, Any]],
|
|
56
|
+
stats: SessionStats,
|
|
57
|
+
out: IO[str],
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Serialise all session data and write to *out*.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
session: Row dict from ``Database.get_session()``.
|
|
63
|
+
messages: List of message row dicts from ``Database.get_messages()``.
|
|
64
|
+
tools: List of tool row dicts from ``Database.get_tools()``.
|
|
65
|
+
errors: List of error row dicts from ``Database.get_errors()``.
|
|
66
|
+
stats: Pre-computed ``SessionStats`` (from Day 12 analytics).
|
|
67
|
+
out: Writable text stream (file or ``io.StringIO``).
|
|
68
|
+
"""
|
|
69
|
+
document = self._build(session, messages, tools, errors, stats)
|
|
70
|
+
json.dump(document, out, indent=self._indent, default=str)
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Internal helpers
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def _build(
|
|
77
|
+
self,
|
|
78
|
+
session: Dict[str, Any],
|
|
79
|
+
messages: List[Dict[str, Any]],
|
|
80
|
+
tools: List[Dict[str, Any]],
|
|
81
|
+
errors: List[Dict[str, Any]],
|
|
82
|
+
stats: SessionStats,
|
|
83
|
+
) -> Dict[str, Any]:
|
|
84
|
+
return {
|
|
85
|
+
"session": self._build_session(session),
|
|
86
|
+
"messages": [self._build_message(m) for m in messages],
|
|
87
|
+
"tools": [self._build_tool(t, stats) for t in tools],
|
|
88
|
+
"errors": [self._build_error(e) for e in errors],
|
|
89
|
+
"stats": self._build_stats(stats),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _build_session(session: Dict[str, Any]) -> Dict[str, Any]:
|
|
94
|
+
return {
|
|
95
|
+
"id": session.get("id"),
|
|
96
|
+
"friendly_name": session.get("friendly_name"),
|
|
97
|
+
"server_command": session.get("server_command"),
|
|
98
|
+
"started_at": session.get("started_at"),
|
|
99
|
+
"ended_at": session.get("ended_at"),
|
|
100
|
+
"duration_seconds": session.get("duration_seconds"),
|
|
101
|
+
"status": session.get("status"),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _build_message(msg: Dict[str, Any]) -> Dict[str, Any]:
|
|
106
|
+
# params / result / error are stored as JSON strings; decode them.
|
|
107
|
+
def _decode(raw: Any) -> Any:
|
|
108
|
+
if isinstance(raw, str):
|
|
109
|
+
try:
|
|
110
|
+
return json.loads(raw)
|
|
111
|
+
except (json.JSONDecodeError, ValueError):
|
|
112
|
+
return raw
|
|
113
|
+
return raw
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"id": msg.get("id"),
|
|
117
|
+
"direction": msg.get("direction"),
|
|
118
|
+
"method": msg.get("method"),
|
|
119
|
+
"timestamp": _iso(msg.get("timestamp")),
|
|
120
|
+
"latency_ms": msg.get("latency_ms"),
|
|
121
|
+
"params": _decode(msg.get("params")),
|
|
122
|
+
"result": _decode(msg.get("result")),
|
|
123
|
+
"error": _decode(msg.get("error")),
|
|
124
|
+
"message_type": msg.get("message_type"),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _build_tool(tool: Dict[str, Any], stats: SessionStats) -> Dict[str, Any]:
|
|
129
|
+
name = tool.get("name") or ""
|
|
130
|
+
# Look up per-tool metrics from the pre-computed stats
|
|
131
|
+
tool_metric = next((t for t in stats.top_tools if t.name == name), None)
|
|
132
|
+
call_count = tool_metric.calls if tool_metric else 0
|
|
133
|
+
avg_latency = (
|
|
134
|
+
round(tool_metric.avg_latency_ms, 2)
|
|
135
|
+
if (tool_metric and tool_metric.avg_latency_ms is not None)
|
|
136
|
+
else None
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
raw_schema = tool.get("input_schema") or "{}"
|
|
140
|
+
try:
|
|
141
|
+
schema = json.loads(raw_schema) if isinstance(raw_schema, str) else raw_schema
|
|
142
|
+
except (json.JSONDecodeError, ValueError):
|
|
143
|
+
schema = {}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"name": name,
|
|
147
|
+
"description": tool.get("description"),
|
|
148
|
+
"input_schema": schema,
|
|
149
|
+
"call_count": call_count,
|
|
150
|
+
"avg_latency_ms": avg_latency,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _build_error(err: Dict[str, Any]) -> Dict[str, Any]:
|
|
155
|
+
return {
|
|
156
|
+
"id": err.get("id"),
|
|
157
|
+
"type": err.get("error_type"),
|
|
158
|
+
"code": err.get("error_code"),
|
|
159
|
+
"message": err.get("error_message"),
|
|
160
|
+
"suggestion": err.get("suggestion"),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _build_stats(stats: SessionStats) -> Dict[str, Any]:
|
|
165
|
+
total_errors = sum(stats.errors_by_category.values())
|
|
166
|
+
total_msgs = stats.total_messages
|
|
167
|
+
error_rate = round(total_errors / total_msgs, 4) if total_msgs > 0 else 0.0
|
|
168
|
+
return {
|
|
169
|
+
"total_messages": total_msgs,
|
|
170
|
+
"client_to_server": stats.client_to_server_count,
|
|
171
|
+
"server_to_client": stats.server_to_client_count,
|
|
172
|
+
"total_errors": total_errors,
|
|
173
|
+
"error_rate": error_rate,
|
|
174
|
+
"tools_called": len(stats.top_tools),
|
|
175
|
+
"avg_latency_ms": round(stats.latency_avg, 2)
|
|
176
|
+
if stats.latency_avg is not None
|
|
177
|
+
else None,
|
|
178
|
+
}
|