mcpmon 0.1.4 → 0.3.0

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.
package/mcpmon.py CHANGED
@@ -1,20 +1,188 @@
1
1
  #!/usr/bin/env python3
2
2
  """mcpmon: Hot reload for MCP servers. Like nodemon, but for MCP."""
3
3
 
4
+ from __future__ import annotations
5
+
4
6
  import argparse
5
7
  import signal
6
8
  import subprocess
7
9
  import sys
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from enum import IntEnum
8
13
  from pathlib import Path
14
+ from typing import TextIO
15
+
16
+ from watchfiles import Change, watch
17
+
18
+
19
+ # =============================================================================
20
+ # Logging
21
+ # =============================================================================
22
+
23
+
24
+ class LogLevel(IntEnum):
25
+ """Logging verbosity levels."""
26
+ QUIET = 0 # Only errors
27
+ NORMAL = 1 # Start, stop, restart events (default)
28
+ VERBOSE = 2 # + file change details
29
+ DEBUG = 3 # + everything
30
+
31
+
32
+ @dataclass
33
+ class Logger:
34
+ """Structured logger with levels, timestamps, and optional file output."""
35
+ level: LogLevel = LogLevel.NORMAL
36
+ show_timestamps: bool = False
37
+ log_file: Path | None = None
38
+ _file_handle: TextIO | None = None
39
+
40
+ def _format(self, msg: str, pid: int | None = None) -> str:
41
+ """Format a log message with optional timestamp and PID."""
42
+ parts = ["[mcpmon"]
43
+
44
+ if self.show_timestamps:
45
+ parts.append(datetime.now().strftime("%H:%M:%S"))
46
+
47
+ if pid is not None:
48
+ parts.append(f"pid:{pid}")
49
+
50
+ prefix = " ".join(parts) + "]"
51
+ return f"{prefix} {msg}"
52
+
53
+ def _write(self, msg: str) -> None:
54
+ """Write to stderr and optionally to log file."""
55
+ print(msg, file=sys.stderr)
56
+
57
+ if self._file_handle:
58
+ # Always include timestamp in file logs
59
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
60
+ self._file_handle.write(f"[{ts}] {msg}\n")
61
+ self._file_handle.flush()
62
+
63
+ def open_file(self) -> None:
64
+ """Open log file if configured."""
65
+ if self.log_file:
66
+ self._file_handle = self.log_file.open("a")
67
+
68
+ def close_file(self) -> None:
69
+ """Close log file if open."""
70
+ if self._file_handle:
71
+ self._file_handle.close()
72
+ self._file_handle = None
73
+
74
+ # --- Log methods by level ---
75
+
76
+ def error(self, msg: str, pid: int | None = None) -> None:
77
+ """Always shown (QUIET+)."""
78
+ self._write(self._format(f"ERROR: {msg}", pid))
79
+
80
+ def info(self, msg: str, pid: int | None = None) -> None:
81
+ """Start, stop, restart events (NORMAL+)."""
82
+ if self.level >= LogLevel.NORMAL:
83
+ self._write(self._format(msg, pid))
84
+
85
+ def verbose(self, msg: str, pid: int | None = None) -> None:
86
+ """File change details (VERBOSE+)."""
87
+ if self.level >= LogLevel.VERBOSE:
88
+ self._write(self._format(msg, pid))
89
+
90
+ def debug(self, msg: str, pid: int | None = None) -> None:
91
+ """Everything else (DEBUG only)."""
92
+ if self.level >= LogLevel.DEBUG:
93
+ self._write(self._format(f"DEBUG: {msg}", pid))
94
+
95
+
96
+ # Global logger instance
97
+ log = Logger()
98
+
99
+
100
+ # =============================================================================
101
+ # Process Management
102
+ # =============================================================================
103
+
104
+
105
+ def terminate_process(proc: subprocess.Popen) -> None:
106
+ """Gracefully terminate process: SIGTERM, wait 2s, SIGKILL."""
107
+ if proc.poll() is not None:
108
+ log.debug(f"Process already exited with code {proc.returncode}", proc.pid)
109
+ return
110
+
111
+ log.debug("Sending SIGTERM", proc.pid)
112
+ proc.terminate()
113
+
114
+ try:
115
+ proc.wait(timeout=2)
116
+ log.debug(f"Process exited with code {proc.returncode}", proc.pid)
117
+ except subprocess.TimeoutExpired:
118
+ log.debug("SIGTERM timeout, sending SIGKILL", proc.pid)
119
+ proc.kill()
120
+ proc.wait()
121
+ log.debug("Process killed", proc.pid)
122
+
123
+
124
+ def start_process(command: list[str]) -> subprocess.Popen:
125
+ """Start the MCP server process."""
126
+ log.debug(f"Spawning: {' '.join(command)}")
127
+ proc = subprocess.Popen(command)
128
+ log.info(f"Started: {' '.join(command)}", proc.pid)
129
+ return proc
9
130
 
10
- from watchfiles import watch, Change
11
131
 
132
+ # =============================================================================
133
+ # File Watching
134
+ # =============================================================================
12
135
 
13
- def parse_args():
136
+
137
+ def get_change_type_name(change_type: Change) -> str:
138
+ """Human-readable change type."""
139
+ return {
140
+ Change.added: "added",
141
+ Change.modified: "modified",
142
+ Change.deleted: "deleted",
143
+ }.get(change_type, "changed")
144
+
145
+
146
+ def should_reload(changes: set, extensions: set[str]) -> tuple[bool, list[tuple[str, str]]]:
147
+ """Check if any changed file matches our extensions.
148
+
149
+ Returns:
150
+ (should_reload, list of (change_type_name, path) for matching files)
151
+ """
152
+ matching = []
153
+ for change_type, path in changes:
154
+ if change_type in (Change.added, Change.modified):
155
+ if Path(path).suffix.lstrip(".") in extensions:
156
+ matching.append((get_change_type_name(change_type), path))
157
+
158
+ return bool(matching), matching
159
+
160
+
161
+ # =============================================================================
162
+ # CLI
163
+ # =============================================================================
164
+
165
+
166
+ def parse_args() -> argparse.Namespace:
14
167
  parser = argparse.ArgumentParser(
15
168
  description="Hot reload wrapper for MCP servers",
16
- usage="mcpmon --watch <dir> [--ext <ext>] -- <command>",
169
+ usage="mcpmon [options] --watch <dir> -- <command>",
170
+ formatter_class=argparse.RawDescriptionHelpFormatter,
171
+ epilog="""
172
+ Logging levels:
173
+ --quiet Only errors
174
+ (default) Start, stop, restart events
175
+ --verbose + file change details
176
+ --debug + everything
177
+
178
+ Examples:
179
+ mcpmon --watch src/ -- python -m my_server
180
+ mcpmon -w . -e py,json --verbose -- node server.js
181
+ mcpmon --timestamps --log-file mcpmon.log -- python server.py
182
+ """,
17
183
  )
184
+
185
+ # Watch options
18
186
  parser.add_argument(
19
187
  "--watch", "-w",
20
188
  type=str,
@@ -27,11 +195,43 @@ def parse_args():
27
195
  default="py",
28
196
  help="File extensions to watch, comma-separated (default: py)",
29
197
  )
198
+
199
+ # Logging options
200
+ log_group = parser.add_mutually_exclusive_group()
201
+ log_group.add_argument(
202
+ "--quiet", "-q",
203
+ action="store_true",
204
+ help="Only show errors",
205
+ )
206
+ log_group.add_argument(
207
+ "--verbose", "-v",
208
+ action="store_true",
209
+ help="Show file change details",
210
+ )
211
+ log_group.add_argument(
212
+ "--debug",
213
+ action="store_true",
214
+ help="Show all debug output",
215
+ )
216
+
217
+ parser.add_argument(
218
+ "--timestamps", "-t",
219
+ action="store_true",
220
+ help="Include timestamps in output",
221
+ )
222
+ parser.add_argument(
223
+ "--log-file", "-l",
224
+ type=Path,
225
+ help="Also write logs to file (always includes timestamps)",
226
+ )
227
+
228
+ # Command
30
229
  parser.add_argument(
31
230
  "command",
32
231
  nargs=argparse.REMAINDER,
33
232
  help="Command to run (after --)",
34
233
  )
234
+
35
235
  args = parser.parse_args()
36
236
 
37
237
  # Remove leading -- from command if present
@@ -44,48 +244,45 @@ def parse_args():
44
244
  return args
45
245
 
46
246
 
47
- def terminate_process(proc: subprocess.Popen) -> None:
48
- """Gracefully terminate process: SIGTERM, wait 2s, SIGKILL."""
49
- if proc.poll() is not None:
50
- return
247
+ # =============================================================================
248
+ # Main
249
+ # =============================================================================
51
250
 
52
- proc.terminate()
53
- try:
54
- proc.wait(timeout=2)
55
- except subprocess.TimeoutExpired:
56
- proc.kill()
57
- proc.wait()
58
-
59
-
60
- def start_process(command: list[str]) -> subprocess.Popen:
61
- """Start the MCP server process."""
62
- return subprocess.Popen(command)
63
251
 
252
+ def main() -> None:
253
+ args = parse_args()
64
254
 
65
- def should_reload(changes: set, extensions: set[str]) -> bool:
66
- """Check if any changed file matches our extensions."""
67
- for change_type, path in changes:
68
- if change_type in (Change.added, Change.modified) and Path(path).suffix.lstrip(".") in extensions:
69
- return True
70
- return False
255
+ # Configure logger
256
+ if args.quiet:
257
+ log.level = LogLevel.QUIET
258
+ elif args.verbose:
259
+ log.level = LogLevel.VERBOSE
260
+ elif args.debug:
261
+ log.level = LogLevel.DEBUG
71
262
 
72
-
73
- def main():
74
- args = parse_args()
263
+ log.show_timestamps = args.timestamps
264
+ log.log_file = args.log_file
265
+ log.open_file()
75
266
 
76
267
  watch_path = Path(args.watch).resolve()
77
268
  extensions = {ext.strip().lstrip(".") for ext in args.ext.split(",")}
78
269
  command = args.command
79
270
 
80
- print(f"[mcpmon] Watching {watch_path} for .{', .'.join(extensions)} changes")
81
- print(f"[mcpmon] Running: {' '.join(command)}")
271
+ log.info(f"Watching {watch_path} for .{', .'.join(sorted(extensions))} changes")
272
+ log.debug(f"Log level: {log.level.name}")
273
+ if log.log_file:
274
+ log.debug(f"Log file: {log.log_file}")
82
275
 
83
276
  proc = start_process(command)
277
+ restart_count = 0
84
278
 
85
- # Handle Ctrl+C gracefully
279
+ # Handle signals gracefully
86
280
  def signal_handler(signum, frame):
87
- print("\n[mcpmon] Shutting down...")
281
+ sig_name = signal.Signals(signum).name
282
+ log.info(f"Received {sig_name}, shutting down...")
88
283
  terminate_process(proc)
284
+ log.info(f"Shutdown complete (restarts: {restart_count})")
285
+ log.close_file()
89
286
  sys.exit(0)
90
287
 
91
288
  signal.signal(signal.SIGINT, signal_handler)
@@ -93,19 +290,30 @@ def main():
93
290
 
94
291
  try:
95
292
  for changes in watch(watch_path):
96
- if should_reload(changes, extensions):
97
- changed_files = [p for _, p in changes]
98
- print(f"[mcpmon] Change detected: {', '.join(changed_files)}")
99
- print("[mcpmon] Restarting...")
293
+ reload_needed, matching_files = should_reload(changes, extensions)
294
+
295
+ if reload_needed:
296
+ # Log each changed file at verbose level
297
+ for change_type, path in matching_files:
298
+ rel_path = Path(path).relative_to(watch_path) if path.startswith(str(watch_path)) else path
299
+ log.verbose(f"File {change_type}: {rel_path}")
100
300
 
301
+ log.info("Restarting...", proc.pid)
101
302
  terminate_process(proc)
102
303
  proc = start_process(command)
304
+ restart_count += 1
305
+ log.info(f"Restart #{restart_count} complete", proc.pid)
306
+ else:
307
+ # Log ignored changes at debug level
308
+ for change_type, path in changes:
309
+ log.debug(f"Ignored {get_change_type_name(change_type)}: {path}")
103
310
 
104
- print("[mcpmon] Server restarted")
105
311
  except KeyboardInterrupt:
106
312
  pass
107
313
  finally:
108
314
  terminate_process(proc)
315
+ log.info(f"Exited (total restarts: {restart_count})")
316
+ log.close_file()
109
317
 
110
318
 
111
319
  if __name__ == "__main__":