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/.github/workflows/release.yml +74 -7
- 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 +245 -38
- package/mcpmon.test.ts +441 -0
- package/mcpmon.ts +202 -35
- package/package.json +9 -3
- package/pyproject.toml +11 -1
- package/tests/__init__.py +1 -0
- package/tests/test_mcpmon.py +493 -0
- package/.github/workflows/publish-npm.yml +0 -50
- package/.github/workflows/publish.yml +0 -26
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
|
|
22
|
-
console.log(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
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(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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("
|
|
110
|
-
|
|
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.
|
|
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",
|
|
@@ -10,12 +10,18 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "bun build --compile mcpmon.ts --outfile dist/mcpmon"
|
|
12
12
|
},
|
|
13
|
-
"keywords": [
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"hot-reload",
|
|
16
|
+
"nodemon",
|
|
17
|
+
"bun",
|
|
18
|
+
"development"
|
|
19
|
+
],
|
|
14
20
|
"author": "be.nvy",
|
|
15
21
|
"license": "MIT",
|
|
16
22
|
"repository": {
|
|
17
23
|
"type": "git",
|
|
18
|
-
"url": "https://github.com/b17z/mcpmon"
|
|
24
|
+
"url": "git+https://github.com/b17z/mcpmon.git"
|
|
19
25
|
},
|
|
20
26
|
"engines": {
|
|
21
27
|
"bun": ">=1.0.0"
|
package/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mcpmon"
|
|
7
|
-
version = "0.
|
|
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
|