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/.github/workflows/release.yml +1 -1
- package/.nojekyll +0 -0
- package/.pre-commit-config.yaml +44 -0
- package/README.md +59 -4
- package/__main__.py +6 -0
- package/index.html +414 -0
- package/mcpmon.py +244 -36
- package/mcpmon.test.ts +441 -0
- package/mcpmon.ts +202 -35
- package/package.json +1 -1
- package/pyproject.toml +11 -1
- package/tests/__init__.py +1 -0
- package/tests/test_mcpmon.py +493 -0
- package/.github/.tmp/.generated-actions/run-pypi-publish-in-docker-container/action.yml +0 -1
- package/dist/mcpmon-0.1.4-py3-none-any.whl +0 -0
- package/dist/mcpmon-0.1.4-py3-none-any.whl.publish.attestation +0 -1
- package/dist/mcpmon-0.1.4.tar.gz +0 -0
- package/dist/mcpmon-0.1.4.tar.gz.publish.attestation +0 -1
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
|
-
|
|
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>
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
279
|
+
# Handle signals gracefully
|
|
86
280
|
def signal_handler(signum, frame):
|
|
87
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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__":
|