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.ts CHANGED
@@ -3,111 +3,278 @@
3
3
  * mcpmon: Hot reload for MCP servers. Like nodemon, but for MCP.
4
4
  */
5
5
 
6
- import { watch } from "fs";
6
+ import { watch, appendFileSync, type WatchListener } from "fs";
7
7
  import { spawn, type Subprocess } from "bun";
8
8
  import { parseArgs } from "util";
9
9
 
10
+ // =============================================================================
11
+ // Types
12
+ // =============================================================================
13
+
14
+ type LogLevel = "quiet" | "normal" | "verbose" | "debug";
15
+
16
+ interface Logger {
17
+ level: LogLevel;
18
+ showTimestamps: boolean;
19
+ logFile: string | null;
20
+ fileHandle: number | null;
21
+ }
22
+
23
+ // =============================================================================
24
+ // Logging
25
+ // =============================================================================
26
+
27
+ const LOG_LEVELS: Record<LogLevel, number> = {
28
+ quiet: 0,
29
+ normal: 1,
30
+ verbose: 2,
31
+ debug: 3,
32
+ };
33
+
34
+ const logger: Logger = {
35
+ level: "normal",
36
+ showTimestamps: false,
37
+ logFile: null,
38
+ fileHandle: null,
39
+ };
40
+
41
+ function getTimestamp(): string {
42
+ const now = new Date();
43
+ return now.toTimeString().slice(0, 8); // HH:MM:SS
44
+ }
45
+
46
+ function getFullTimestamp(): string {
47
+ return new Date().toISOString().replace("T", " ").slice(0, 19);
48
+ }
49
+
50
+ function formatMessage(msg: string, pid?: number): string {
51
+ const parts = ["[mcpmon"];
52
+
53
+ if (logger.showTimestamps) {
54
+ parts.push(getTimestamp());
55
+ }
56
+
57
+ if (pid !== undefined) {
58
+ parts.push(`pid:${pid}`);
59
+ }
60
+
61
+ return `${parts.join(" ")}] ${msg}`;
62
+ }
63
+
64
+ function writeLog(msg: string): void {
65
+ console.error(msg);
66
+
67
+ if (logger.logFile) {
68
+ const fileMsg = `[${getFullTimestamp()}] ${msg}\n`;
69
+ try {
70
+ appendFileSync(logger.logFile, fileMsg);
71
+ } catch {
72
+ // Ignore file write errors
73
+ }
74
+ }
75
+ }
76
+
77
+ const log = {
78
+ error(msg: string, pid?: number): void {
79
+ writeLog(formatMessage(`ERROR: ${msg}`, pid));
80
+ },
81
+
82
+ info(msg: string, pid?: number): void {
83
+ if (LOG_LEVELS[logger.level] >= LOG_LEVELS.normal) {
84
+ writeLog(formatMessage(msg, pid));
85
+ }
86
+ },
87
+
88
+ verbose(msg: string, pid?: number): void {
89
+ if (LOG_LEVELS[logger.level] >= LOG_LEVELS.verbose) {
90
+ writeLog(formatMessage(msg, pid));
91
+ }
92
+ },
93
+
94
+ debug(msg: string, pid?: number): void {
95
+ if (LOG_LEVELS[logger.level] >= LOG_LEVELS.debug) {
96
+ writeLog(formatMessage(`DEBUG: ${msg}`, pid));
97
+ }
98
+ },
99
+ };
100
+
101
+ // =============================================================================
102
+ // CLI
103
+ // =============================================================================
104
+
105
+ const HELP = `mcpmon - Hot reload for MCP servers
106
+
107
+ Usage: mcpmon [options] -- <command>
108
+
109
+ Options:
110
+ -w, --watch <dir> Directory to watch (default: .)
111
+ -e, --ext <exts> Extensions to watch, comma-separated (default: py)
112
+ -q, --quiet Only show errors
113
+ -v, --verbose Show file change details
114
+ --debug Show all debug output
115
+ -t, --timestamps Include timestamps in output
116
+ -l, --log-file <file> Also write logs to file (always includes timestamps)
117
+ -h, --help Show this help
118
+
119
+ Logging levels:
120
+ --quiet Only errors
121
+ (default) Start, stop, restart events
122
+ --verbose + file change details
123
+ --debug + everything
124
+
125
+ Examples:
126
+ mcpmon -- python server.py
127
+ mcpmon --watch src/ --ext py,json -- python -m myserver
128
+ mcpmon --timestamps --log-file mcpmon.log -- python server.py
129
+ `;
130
+
10
131
  const { values, positionals } = parseArgs({
11
132
  args: Bun.argv.slice(2),
12
133
  options: {
13
134
  watch: { type: "string", short: "w", default: "." },
14
135
  ext: { type: "string", short: "e", default: "py" },
136
+ quiet: { type: "boolean", short: "q", default: false },
137
+ verbose: { type: "boolean", short: "v", default: false },
138
+ debug: { type: "boolean", default: false },
139
+ timestamps: { type: "boolean", short: "t", default: false },
140
+ "log-file": { type: "string", short: "l" },
15
141
  help: { type: "boolean", short: "h", default: false },
16
142
  },
17
143
  allowPositionals: true,
18
144
  strict: false,
19
145
  });
20
146
 
21
- if (values.help || positionals.length === 0) {
22
- console.log(`mcpmon - Hot reload for MCP servers
23
-
24
- Usage: mcpmon [options] -- <command>
25
-
26
- Options:
27
- -w, --watch <dir> Directory to watch (default: .)
28
- -e, --ext <exts> Extensions to watch, comma-separated (default: py)
29
- -h, --help Show this help
30
-
31
- Examples:
32
- mcpmon -- python server.py
33
- mcpmon --watch src/ --ext py,json -- python -m myserver
34
- mcpmon --watch src/crucible/ -- crucible-mcp
35
- `);
147
+ if (values.help) {
148
+ console.log(HELP);
36
149
  process.exit(0);
37
150
  }
38
151
 
152
+ // Configure logger
153
+ if (values.quiet) {
154
+ logger.level = "quiet";
155
+ } else if (values.debug) {
156
+ logger.level = "debug";
157
+ } else if (values.verbose) {
158
+ logger.level = "verbose";
159
+ }
160
+
161
+ logger.showTimestamps = values.timestamps as boolean;
162
+ logger.logFile = values["log-file"] as string | null;
163
+
39
164
  // Remove leading "--" if present
40
165
  const command = positionals[0] === "--" ? positionals.slice(1) : positionals;
41
166
 
42
167
  if (command.length === 0) {
43
- console.error("[mcpmon] Error: No command specified");
168
+ log.error("No command specified. Use: mcpmon --watch src/ -- <command>");
44
169
  process.exit(1);
45
170
  }
46
171
 
47
172
  const watchDir = values.watch as string;
48
- const extensions = new Set((values.ext as string).split(",").map(e => e.trim().replace(/^\./, "")));
173
+ const extensions = new Set(
174
+ (values.ext as string).split(",").map((e) => e.trim().replace(/^\./, ""))
175
+ );
176
+
177
+ // =============================================================================
178
+ // Process Management
179
+ // =============================================================================
49
180
 
50
181
  let proc: Subprocess | null = null;
182
+ let restartCount = 0;
51
183
 
52
184
  function startServer(): void {
53
- console.log(`[mcpmon] Starting: ${command.join(" ")}`);
185
+ log.debug(`Spawning: ${command.join(" ")}`);
54
186
  proc = spawn({
55
187
  cmd: command,
56
188
  stdout: "inherit",
57
189
  stderr: "inherit",
58
190
  stdin: "inherit",
59
191
  });
192
+ log.info(`Started: ${command.join(" ")}`, proc.pid);
60
193
  }
61
194
 
62
195
  async function stopServer(): Promise<void> {
63
- if (!proc || proc.exitCode !== null) return;
196
+ if (!proc) return;
197
+
198
+ const pid = proc.pid;
64
199
 
200
+ if (proc.exitCode !== null) {
201
+ log.debug(`Process already exited with code ${proc.exitCode}`, pid);
202
+ return;
203
+ }
204
+
205
+ log.debug("Sending SIGTERM", pid);
65
206
  proc.kill("SIGTERM");
66
207
 
67
208
  // Wait up to 2 seconds for graceful shutdown
68
209
  const timeout = setTimeout(() => {
69
210
  if (proc && proc.exitCode === null) {
211
+ log.debug("SIGTERM timeout, sending SIGKILL", pid);
70
212
  proc.kill("SIGKILL");
71
213
  }
72
214
  }, 2000);
73
215
 
74
216
  await proc.exited;
75
217
  clearTimeout(timeout);
218
+ log.debug(`Process exited with code ${proc.exitCode}`, pid);
76
219
  }
77
220
 
78
221
  async function restartServer(): Promise<void> {
222
+ const oldPid = proc?.pid;
223
+ log.info("Restarting...", oldPid);
79
224
  await stopServer();
80
225
  startServer();
226
+ restartCount++;
227
+ log.info(`Restart #${restartCount} complete`, proc?.pid);
81
228
  }
82
229
 
230
+ // =============================================================================
231
+ // File Watching
232
+ // =============================================================================
233
+
83
234
  function shouldReload(filename: string | null): boolean {
84
235
  if (!filename) return false;
85
236
  const ext = filename.split(".").pop() || "";
86
237
  return extensions.has(ext);
87
238
  }
88
239
 
89
- // Start server
90
- console.log(`[mcpmon] Watching ${watchDir} for .${[...extensions].join(", .")} changes`);
240
+ function getChangeType(event: string): string {
241
+ return event === "rename" ? "added/deleted" : "modified";
242
+ }
243
+
244
+ // =============================================================================
245
+ // Main
246
+ // =============================================================================
247
+
248
+ log.info(`Watching ${watchDir} for .${[...extensions].sort().join(", .")} changes`);
249
+ log.debug(`Log level: ${logger.level}`);
250
+ if (logger.logFile) {
251
+ log.debug(`Log file: ${logger.logFile}`);
252
+ }
253
+
91
254
  startServer();
92
255
 
93
256
  // Watch for changes
94
- const watcher = watch(watchDir, { recursive: true }, async (event, filename) => {
95
- if (event === "change" && shouldReload(filename)) {
96
- console.log(`[mcpmon] ${filename} changed, restarting...`);
97
- await restartServer();
257
+ const watcher = watch(
258
+ watchDir,
259
+ { recursive: true },
260
+ async (event, filename) => {
261
+ if (shouldReload(filename)) {
262
+ log.verbose(`File ${getChangeType(event)}: ${filename}`);
263
+ await restartServer();
264
+ } else if (filename) {
265
+ log.debug(`Ignored ${getChangeType(event)}: ${filename}`);
266
+ }
98
267
  }
99
- });
268
+ );
100
269
 
101
270
  // Handle shutdown
102
- process.on("SIGINT", async () => {
103
- console.log("\n[mcpmon] Shutting down...");
271
+ async function shutdown(signal: string): Promise<void> {
272
+ log.info(`Received ${signal}, shutting down...`);
104
273
  watcher.close();
105
274
  await stopServer();
275
+ log.info(`Shutdown complete (restarts: ${restartCount})`);
106
276
  process.exit(0);
107
- });
277
+ }
108
278
 
109
- process.on("SIGTERM", async () => {
110
- watcher.close();
111
- await stopServer();
112
- process.exit(0);
113
- });
279
+ process.on("SIGINT", () => shutdown("SIGINT"));
280
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpmon",
3
- "version": "0.1.4",
3
+ "version": "0.3.0",
4
4
  "description": "Hot reload for MCP servers. Like nodemon, but for MCP.",
5
5
  "type": "module",
6
6
  "main": "mcpmon.ts",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mcpmon"
7
- version = "0.1.4"
7
+ version = "0.3.0"
8
8
  description = "Hot reload for MCP servers. Like nodemon, but for MCP."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -25,8 +25,18 @@ dependencies = [
25
25
  "watchfiles>=0.20.0",
26
26
  ]
27
27
 
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=7.0.0",
31
+ "pytest-timeout>=2.0.0",
32
+ ]
33
+
28
34
  [project.scripts]
29
35
  mcpmon = "mcpmon:main"
30
36
 
31
37
  [project.urls]
32
38
  Homepage = "https://github.com/b17z/mcpmon"
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ timeout = 30
@@ -0,0 +1 @@
1
+ # mcpmon tests