mcpmon 0.1.0 → 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
- import subprocess
6
7
  import signal
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
130
+
9
131
 
10
- from watchfiles import watch, Change
132
+ # =============================================================================
133
+ # File Watching
134
+ # =============================================================================
11
135
 
12
136
 
13
- def parse_args():
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,49 +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
51
-
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)
247
+ # =============================================================================
248
+ # Main
249
+ # =============================================================================
63
250
 
64
251
 
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):
69
- if Path(path).suffix.lstrip(".") in extensions:
70
- return True
71
- return False
252
+ def main() -> None:
253
+ args = parse_args()
72
254
 
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
73
262
 
74
- def main():
75
- args = parse_args()
263
+ log.show_timestamps = args.timestamps
264
+ log.log_file = args.log_file
265
+ log.open_file()
76
266
 
77
267
  watch_path = Path(args.watch).resolve()
78
268
  extensions = {ext.strip().lstrip(".") for ext in args.ext.split(",")}
79
269
  command = args.command
80
270
 
81
- print(f"[mcpmon] Watching {watch_path} for .{', .'.join(extensions)} changes")
82
- 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}")
83
275
 
84
276
  proc = start_process(command)
277
+ restart_count = 0
85
278
 
86
- # Handle Ctrl+C gracefully
279
+ # Handle signals gracefully
87
280
  def signal_handler(signum, frame):
88
- print("\n[mcpmon] Shutting down...")
281
+ sig_name = signal.Signals(signum).name
282
+ log.info(f"Received {sig_name}, shutting down...")
89
283
  terminate_process(proc)
284
+ log.info(f"Shutdown complete (restarts: {restart_count})")
285
+ log.close_file()
90
286
  sys.exit(0)
91
287
 
92
288
  signal.signal(signal.SIGINT, signal_handler)
@@ -94,19 +290,30 @@ def main():
94
290
 
95
291
  try:
96
292
  for changes in watch(watch_path):
97
- if should_reload(changes, extensions):
98
- changed_files = [p for _, p in changes]
99
- print(f"[mcpmon] Change detected: {', '.join(changed_files)}")
100
- 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}")
101
300
 
301
+ log.info("Restarting...", proc.pid)
102
302
  terminate_process(proc)
103
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}")
104
310
 
105
- print("[mcpmon] Server restarted")
106
311
  except KeyboardInterrupt:
107
312
  pass
108
313
  finally:
109
314
  terminate_process(proc)
315
+ log.info(f"Exited (total restarts: {restart_count})")
316
+ log.close_file()
110
317
 
111
318
 
112
319
  if __name__ == "__main__":