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/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,6 @@
1
+ """Exporters package – JSON, Markdown, and OTLP session exporters."""
2
+
3
+ from mcp_debugger.exporters.json_exporter import JSONExporter
4
+ from mcp_debugger.exporters.otlp_replay_exporter import OTLPReplayExporter
5
+
6
+ __all__ = ["JSONExporter", "OTLPReplayExporter"]
@@ -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
+ }