@mctx-ai/mcp-dev 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mctx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@mctx-ai/mcp-dev",
3
+ "version": "0.3.0",
4
+ "description": "Local development server for @mctx-ai/mcp-server with hot reload",
5
+ "type": "module",
6
+ "main": "./src/cli.js",
7
+ "bin": {
8
+ "mctx-dev": "./src/cli.js"
9
+ },
10
+ "exports": {
11
+ ".": "./src/cli.js"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "development",
20
+ "hot-reload",
21
+ "dev-server"
22
+ ],
23
+ "author": "mctx, Inc.",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/mctx-ai/mcp-server.git",
28
+ "directory": "packages/dev"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/mctx-ai/mcp-server/issues"
32
+ },
33
+ "homepage": "https://docs.mctx.ai",
34
+ "peerDependencies": {
35
+ "@mctx-ai/mcp-server": "^0.3.0"
36
+ },
37
+ "scripts": {
38
+ "test": "node --test",
39
+ "lint": "echo 'Linting not configured yet'"
40
+ }
41
+ }
package/src/cli.js ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @mctx-ai/mcp-dev CLI Entry Point
5
+ *
6
+ * Usage: npx mctx-dev <entry-file>
7
+ * Example: npx mctx-dev index.js
8
+ */
9
+
10
+ import { existsSync } from "fs";
11
+ import { resolve } from "path";
12
+ import { pathToFileURL } from "url";
13
+ import { startDevServer } from "./server.js";
14
+
15
+ // Parse command line arguments
16
+ const args = process.argv.slice(2);
17
+
18
+ // Check for --help flag
19
+ if (args.includes("--help") || args.includes("-h")) {
20
+ console.log(`
21
+ mctx-dev - Local development server for MCP servers
22
+
23
+ Usage:
24
+ npx mctx-dev <entry-file> [options]
25
+
26
+ Options:
27
+ --port <number> Port to listen on (default: 3000)
28
+ -h, --help Show this help message
29
+
30
+ Examples:
31
+ npx mctx-dev index.js
32
+ npx mctx-dev index.js --port 8080
33
+
34
+ Environment Variables:
35
+ PORT Port to listen on (overridden by --port flag)
36
+ `);
37
+ process.exit(0);
38
+ }
39
+
40
+ // Parse entry file
41
+ const entryFile = args.find((arg) => !arg.startsWith("--"));
42
+
43
+ if (!entryFile) {
44
+ console.error("Error: Entry file is required");
45
+ console.error("Usage: npx mctx-dev <entry-file>");
46
+ console.error('Run "npx mctx-dev --help" for more information');
47
+ process.exit(1);
48
+ }
49
+
50
+ // Parse port from flags or environment
51
+ const portFlagIndex = args.indexOf("--port");
52
+ const port =
53
+ portFlagIndex !== -1 && args[portFlagIndex + 1]
54
+ ? parseInt(args[portFlagIndex + 1], 10)
55
+ : parseInt(process.env.PORT || "3000", 10);
56
+
57
+ if (isNaN(port) || port < 1 || port > 65535) {
58
+ console.error(
59
+ `Error: Invalid port "${args[portFlagIndex + 1] || process.env.PORT}"`,
60
+ );
61
+ process.exit(1);
62
+ }
63
+
64
+ // Resolve entry file to absolute path
65
+ const entryPath = resolve(process.cwd(), entryFile);
66
+
67
+ // Check if file exists (Fix #4)
68
+ if (!existsSync(entryPath)) {
69
+ console.error(`Error: File not found: ${entryFile}`);
70
+ console.error(`Resolved path: ${entryPath}`);
71
+ process.exit(1);
72
+ }
73
+
74
+ // Convert to file URL for dynamic import
75
+ const entryUrl = pathToFileURL(entryPath).href;
76
+
77
+ // Start dev server
78
+ try {
79
+ await startDevServer(entryUrl, port);
80
+ } catch (error) {
81
+ // Handle EADDRINUSE port conflicts (Fix #1)
82
+ if (error.code === "EADDRINUSE") {
83
+ console.error(`\nError: Port ${port} is already in use.`);
84
+ console.error(
85
+ `Try a different port: npx mctx-dev ${entryFile} --port ${port + 1}`,
86
+ );
87
+ process.exit(1);
88
+ }
89
+
90
+ console.error("Failed to start dev server:", error.message);
91
+
92
+ if (error.code === "ERR_MODULE_NOT_FOUND") {
93
+ console.error(`\nCould not find module: ${entryFile}`);
94
+ console.error("Make sure the file exists and the path is correct.");
95
+ } else if (error.message.includes("default export")) {
96
+ console.error("\nMake sure your entry file has a default export.");
97
+ console.error("Example: export default app;");
98
+ } else {
99
+ console.error("\nStack trace:");
100
+ console.error(error.stack);
101
+ }
102
+
103
+ process.exit(1);
104
+ }
package/src/server.js ADDED
@@ -0,0 +1,548 @@
1
+ /**
2
+ * @mctx-ai/mcp-dev Server
3
+ *
4
+ * HTTP server that wraps the app's fetch handler with developer-friendly features:
5
+ * - Initialize handshake handling
6
+ * - Request/response logging with timing
7
+ * - Rich error messages with stack traces
8
+ * - Hot reload support
9
+ */
10
+
11
+ import { createServer } from "http";
12
+ import { watch } from "./watcher.js";
13
+
14
+ // ANSI color codes for logging
15
+ const colors = {
16
+ reset: "\x1b[0m",
17
+ bright: "\x1b[1m",
18
+ dim: "\x1b[2m",
19
+ cyan: "\x1b[36m",
20
+ green: "\x1b[32m",
21
+ yellow: "\x1b[33m",
22
+ red: "\x1b[31m",
23
+ gray: "\x1b[90m",
24
+ };
25
+
26
+ /**
27
+ * Format timestamp for logs
28
+ */
29
+ function timestamp() {
30
+ return new Date().toISOString().split("T")[1].split(".")[0];
31
+ }
32
+
33
+ /**
34
+ * Log with timestamp and color (Fix #8: separated framework vs request logs)
35
+ */
36
+ function log(message, color = colors.reset) {
37
+ console.log(
38
+ `${colors.gray}[${timestamp()}]${colors.reset} ${color}${message}${colors.reset}`,
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Log framework events (startup, reload, errors)
44
+ */
45
+ function logFramework(message, color = colors.reset) {
46
+ console.log(
47
+ `${colors.gray}[${timestamp()}]${colors.reset} ${colors.bright}[mctx-dev]${colors.reset} ${color}${message}${colors.reset}`,
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Handle MCP initialize handshake
53
+ * This is normally handled by the MCP client (Claude Desktop), but we need to
54
+ * handle it locally for development.
55
+ */
56
+ function handleInitialize(rpcRequest) {
57
+ const { method, params = {} } = rpcRequest;
58
+
59
+ if (method === "initialize") {
60
+ // Respond with server capabilities
61
+ return {
62
+ jsonrpc: "2.0",
63
+ id: rpcRequest.id,
64
+ result: {
65
+ protocolVersion: "2024-11-05",
66
+ capabilities: {
67
+ tools: {},
68
+ resources: {},
69
+ prompts: {},
70
+ logging: {},
71
+ },
72
+ serverInfo: {
73
+ name: "mctx-dev",
74
+ version: "0.1.0",
75
+ },
76
+ },
77
+ };
78
+ }
79
+
80
+ if (method === "initialized") {
81
+ // Fix #3: Return special marker for notifications (no response per JSON-RPC 2.0)
82
+ return { _notification: true };
83
+ }
84
+
85
+ if (method === "ping") {
86
+ // Respond to ping
87
+ return {
88
+ jsonrpc: "2.0",
89
+ id: rpcRequest.id,
90
+ result: {},
91
+ };
92
+ }
93
+
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Extract method name and arguments for logging
99
+ */
100
+ function formatMethod(rpcRequest) {
101
+ const { method, params } = rpcRequest;
102
+
103
+ // For tools/call, show tool name
104
+ if (method === "tools/call" && params?.name) {
105
+ return `${method} (${params.name})`;
106
+ }
107
+
108
+ // For resources/read, show URI
109
+ if (method === "resources/read" && params?.uri) {
110
+ return `${method} (${params.uri})`;
111
+ }
112
+
113
+ // For prompts/get, show prompt name
114
+ if (method === "prompts/get" && params?.name) {
115
+ return `${method} (${params.name})`;
116
+ }
117
+
118
+ return method;
119
+ }
120
+
121
+ /**
122
+ * Format error for display with helpful hints
123
+ */
124
+ function formatError(error, rpcRequest) {
125
+ let formatted = `${colors.red}${colors.bright}Error:${colors.reset} ${error.message}\n`;
126
+
127
+ // Add stack trace if available
128
+ if (error.stack) {
129
+ const stack = error.stack
130
+ .split("\n")
131
+ .slice(1) // Skip first line (error message)
132
+ .map((line) => ` ${colors.dim}${line.trim()}${colors.reset}`)
133
+ .join("\n");
134
+ formatted += `${stack}\n`;
135
+ }
136
+
137
+ // Add helpful hints for common errors
138
+ if (error.message.includes("not found")) {
139
+ formatted += `\n${colors.yellow}Hint:${colors.reset} Check if the ${rpcRequest.method.split("/")[0]} is registered in your server.\n`;
140
+ } else if (error.message.includes("required")) {
141
+ formatted += `\n${colors.yellow}Hint:${colors.reset} Check your request parameters. Some fields might be missing.\n`;
142
+ } else if (error.message.includes("undefined")) {
143
+ formatted += `\n${colors.yellow}Hint:${colors.reset} Did you forget to return a value from your handler?\n`;
144
+ }
145
+
146
+ return formatted;
147
+ }
148
+
149
+ /**
150
+ * Create Request-like object compatible with app's fetch handler
151
+ */
152
+ function createRequest(body) {
153
+ return {
154
+ method: "POST",
155
+ headers: {
156
+ "content-type": "application/json",
157
+ },
158
+ async json() {
159
+ return body;
160
+ },
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Start the development server
166
+ */
167
+ export async function startDevServer(entryUrl, port) {
168
+ let app = null;
169
+ let appModule = null;
170
+
171
+ // Check if verbose logging is enabled
172
+ const isVerbose = process.env.MCTX_VERBOSE === "true";
173
+
174
+ // Load the user's app
175
+ async function loadApp() {
176
+ try {
177
+ // Clear module from cache for hot reload
178
+ if (entryUrl.startsWith("file://")) {
179
+ const modulePath = entryUrl;
180
+ // Add cache-busting query parameter for ES modules
181
+ const cacheBustedUrl = `${modulePath}?t=${Date.now()}`;
182
+ appModule = await import(cacheBustedUrl);
183
+ } else {
184
+ appModule = await import(entryUrl);
185
+ }
186
+
187
+ app = appModule.default;
188
+
189
+ if (!app) {
190
+ throw new Error(
191
+ "Entry file must have a default export (the app instance)",
192
+ );
193
+ }
194
+
195
+ if (typeof app.fetch !== "function") {
196
+ throw new Error(
197
+ "App must have a fetch method (created via createServer())",
198
+ );
199
+ }
200
+
201
+ return true;
202
+ } catch (error) {
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ // Initial load (Fix #2: handle syntax errors gracefully)
208
+ try {
209
+ await loadApp();
210
+ } catch (error) {
211
+ // Show error and continue - watcher will retry on file changes
212
+ logFramework(`Failed to load ${entryUrl.split("/").pop()}`, colors.red);
213
+
214
+ if (error instanceof SyntaxError) {
215
+ console.error(
216
+ `${colors.red}${colors.bright}SyntaxError:${colors.reset} ${error.message}`,
217
+ );
218
+ if (error.stack) {
219
+ const stackLines = error.stack.split("\n").slice(1, 4);
220
+ console.error(colors.dim + stackLines.join("\n") + colors.reset);
221
+ }
222
+ logFramework(
223
+ "Watching for changes... fix the error and save to retry.",
224
+ colors.yellow,
225
+ );
226
+ } else {
227
+ console.error(formatError(error, { method: "initial-load" }));
228
+ }
229
+ }
230
+
231
+ // Start file watcher for hot reload
232
+ const entryPath = new URL(entryUrl).pathname;
233
+ const watcherInfo = watch(entryPath, async () => {
234
+ try {
235
+ await loadApp();
236
+ logFramework("Reload successful", colors.green);
237
+ } catch (error) {
238
+ logFramework("Reload failed", colors.red);
239
+
240
+ if (error instanceof SyntaxError) {
241
+ console.error(
242
+ `${colors.red}${colors.bright}SyntaxError:${colors.reset} ${error.message}`,
243
+ );
244
+ if (error.stack) {
245
+ const stackLines = error.stack.split("\n").slice(1, 4);
246
+ console.error(colors.dim + stackLines.join("\n") + colors.reset);
247
+ }
248
+ } else {
249
+ console.error(formatError(error, { method: "reload" }));
250
+ }
251
+ }
252
+ });
253
+
254
+ // Create HTTP server
255
+ const server = createServer(async (req, res) => {
256
+ // Fix #2: If app failed to load initially, return error
257
+ if (!app) {
258
+ res.writeHead(503, { "Content-Type": "application/json" });
259
+ res.end(
260
+ JSON.stringify({
261
+ jsonrpc: "2.0",
262
+ error: {
263
+ code: -32000,
264
+ message:
265
+ "Server initialization failed - fix syntax errors and save to retry",
266
+ },
267
+ id: null,
268
+ }),
269
+ );
270
+ return;
271
+ }
272
+
273
+ // Only accept POST requests
274
+ if (req.method !== "POST") {
275
+ res.writeHead(405, { "Content-Type": "application/json" });
276
+ res.end(
277
+ JSON.stringify({
278
+ jsonrpc: "2.0",
279
+ error: {
280
+ code: -32600,
281
+ message: "Invalid Request - Only POST method is supported",
282
+ },
283
+ id: null,
284
+ }),
285
+ );
286
+ return;
287
+ }
288
+
289
+ // Read request body (Fix #6: add timeout protection)
290
+ let body = "";
291
+ let timedOut = false;
292
+
293
+ const timeout = setTimeout(() => {
294
+ timedOut = true;
295
+ req.destroy();
296
+ res.writeHead(408, { "Content-Type": "application/json" });
297
+ res.end(
298
+ JSON.stringify({
299
+ jsonrpc: "2.0",
300
+ error: {
301
+ code: -32000,
302
+ message: "Request timeout - body not received within 30s",
303
+ },
304
+ id: null,
305
+ }),
306
+ );
307
+ logFramework("Request timeout", colors.red);
308
+ }, 30000);
309
+
310
+ req.on("data", (chunk) => {
311
+ body += chunk.toString();
312
+ });
313
+
314
+ req.on("end", async () => {
315
+ clearTimeout(timeout);
316
+
317
+ if (timedOut) {
318
+ return;
319
+ }
320
+
321
+ let rpcRequest;
322
+ const startTime = Date.now();
323
+
324
+ try {
325
+ // Parse JSON body
326
+ rpcRequest = JSON.parse(body);
327
+ } catch (error) {
328
+ // Fix #9: include body snippet in parse error
329
+ const bodySnippet =
330
+ body.length > 100 ? body.substring(0, 100) + "..." : body;
331
+
332
+ res.writeHead(400, { "Content-Type": "application/json" });
333
+ res.end(
334
+ JSON.stringify({
335
+ jsonrpc: "2.0",
336
+ error: {
337
+ code: -32700,
338
+ message: `Parse error - Invalid JSON: ${error.message}`,
339
+ data: { bodySnippet },
340
+ },
341
+ id: null,
342
+ }),
343
+ );
344
+
345
+ log(`${colors.red}✗${colors.reset} Parse error`, colors.red);
346
+ console.error(
347
+ `${colors.dim}Body snippet: ${bodySnippet}${colors.reset}`,
348
+ );
349
+ return;
350
+ }
351
+
352
+ // Log incoming request
353
+ const methodDisplay = formatMethod(rpcRequest);
354
+ log(`${colors.cyan}→${colors.reset} ${methodDisplay}`, colors.dim);
355
+
356
+ // Verbose logging: log full request body (skip initialize/initialized)
357
+ if (
358
+ isVerbose &&
359
+ rpcRequest.method !== "initialize" &&
360
+ rpcRequest.method !== "initialized"
361
+ ) {
362
+ console.log(`${colors.dim}[verbose] Request:${colors.reset}`);
363
+ console.log(JSON.stringify(rpcRequest, null, 2));
364
+ }
365
+
366
+ try {
367
+ // Handle initialize handshake locally
368
+ const initResponse = handleInitialize(rpcRequest);
369
+ if (initResponse !== null) {
370
+ const elapsed = Date.now() - startTime;
371
+
372
+ // Fix #3: For notifications (initialized), send 204 No Content
373
+ if (initResponse._notification === true) {
374
+ res.writeHead(204);
375
+ res.end();
376
+ log(
377
+ `${colors.green}←${colors.reset} 204 (${elapsed}ms)`,
378
+ colors.dim,
379
+ );
380
+ return;
381
+ }
382
+
383
+ // Fix #5: Include app capabilities if available
384
+ if (rpcRequest.method === "initialize" && app) {
385
+ const capabilities = initResponse.result.capabilities;
386
+
387
+ // Merge app capabilities if exposed
388
+ if (app.tools && typeof app.tools.list === "function") {
389
+ capabilities.tools = app.tools.capabilities || {};
390
+ }
391
+ if (app.resources && typeof app.resources.list === "function") {
392
+ capabilities.resources = app.resources.capabilities || {};
393
+ }
394
+ if (app.prompts && typeof app.prompts.list === "function") {
395
+ capabilities.prompts = app.prompts.capabilities || {};
396
+ }
397
+ }
398
+
399
+ res.writeHead(200, { "Content-Type": "application/json" });
400
+ res.end(JSON.stringify(initResponse));
401
+ log(`${colors.green}←${colors.reset} 200 (${elapsed}ms)`, colors.dim);
402
+ return;
403
+ }
404
+
405
+ // Delegate to app's fetch handler
406
+ const request = createRequest(rpcRequest);
407
+ const response = await app.fetch(request, {}, {});
408
+
409
+ const elapsed = Date.now() - startTime;
410
+
411
+ // Read response
412
+ const responseText = await response.text();
413
+ const statusCode = response.status;
414
+
415
+ // Send response
416
+ res.writeHead(statusCode, { "Content-Type": "application/json" });
417
+ res.end(responseText);
418
+
419
+ // Log response
420
+ const statusColor =
421
+ statusCode >= 200 && statusCode < 300 ? colors.green : colors.red;
422
+ log(
423
+ `${statusColor}←${colors.reset} ${statusCode} (${elapsed}ms)`,
424
+ colors.dim,
425
+ );
426
+
427
+ // Verbose logging: log full response body (skip initialize/initialized)
428
+ if (
429
+ isVerbose &&
430
+ rpcRequest.method !== "initialize" &&
431
+ rpcRequest.method !== "initialized"
432
+ ) {
433
+ console.log(`${colors.dim}[verbose] Response:${colors.reset}`);
434
+ try {
435
+ const responseJson = JSON.parse(responseText);
436
+ console.log(JSON.stringify(responseJson, null, 2));
437
+ } catch {
438
+ console.log(responseText);
439
+ }
440
+ }
441
+
442
+ // Slow tool warning: if tools/call took >1000ms
443
+ if (rpcRequest.method === "tools/call" && elapsed > 1000) {
444
+ const toolName = rpcRequest.params?.name || "unknown";
445
+ log(
446
+ `${colors.yellow}⚠️ Slow tool: ${toolName} took ${elapsed}ms${colors.reset}`,
447
+ colors.yellow,
448
+ );
449
+ }
450
+
451
+ // If error, show details
452
+ if (statusCode >= 400) {
453
+ try {
454
+ const errorResponse = JSON.parse(responseText);
455
+ if (errorResponse.error) {
456
+ console.error(
457
+ formatError(
458
+ new Error(errorResponse.error.message || "Unknown error"),
459
+ rpcRequest,
460
+ ),
461
+ );
462
+ }
463
+ } catch {
464
+ // Ignore parse errors
465
+ }
466
+ }
467
+ } catch (error) {
468
+ const elapsed = Date.now() - startTime;
469
+
470
+ // Return error response
471
+ res.writeHead(200, { "Content-Type": "application/json" });
472
+ res.end(
473
+ JSON.stringify({
474
+ jsonrpc: "2.0",
475
+ error: {
476
+ code: -32603,
477
+ message: error.message || "Internal error",
478
+ },
479
+ id: rpcRequest.id || null,
480
+ }),
481
+ );
482
+
483
+ log(`${colors.red}←${colors.reset} error (${elapsed}ms)`, colors.red);
484
+ console.error(formatError(error, rpcRequest));
485
+ }
486
+ });
487
+ });
488
+
489
+ // Fix #1: Handle EADDRINUSE port conflicts
490
+ server.on("error", (error) => {
491
+ if (error.code === "EADDRINUSE") {
492
+ // Propagate to CLI for proper error handling
493
+ throw error;
494
+ } else {
495
+ logFramework(`Server error: ${error.message}`, colors.red);
496
+ throw error;
497
+ }
498
+ });
499
+
500
+ // Start listening
501
+ server.listen(port, () => {
502
+ logFramework(`Server running at http://localhost:${port}`, colors.cyan);
503
+
504
+ // Format watched directories for display
505
+ const watchedDirsDisplay = watcherInfo.watchedDirs
506
+ .map(
507
+ ({ path, recursive }) =>
508
+ ` ${colors.dim}${path}${recursive ? " (recursive)" : ""}${colors.reset}`,
509
+ )
510
+ .join("\n");
511
+
512
+ console.log(`
513
+ ${colors.bright}${colors.cyan}🔧 mctx dev server running at http://localhost:${port}${colors.reset}
514
+
515
+ ${colors.bright}Test with curl:${colors.reset}
516
+ ${colors.dim}curl -X POST http://localhost:${port} \\
517
+ -H "Content-Type: application/json" \\
518
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'${colors.reset}
519
+
520
+ ${colors.bright}Claude Desktop config${colors.reset} ${colors.dim}(~/.config/claude/claude_desktop_config.json):${colors.reset}
521
+ ${colors.dim}{
522
+ "mcpServers": {
523
+ "my-server": {
524
+ "command": "npx",
525
+ "args": ["mctx-dev", "${entryPath}"]
526
+ }
527
+ }
528
+ }${colors.reset}
529
+
530
+ ${colors.bright}Watching for changes:${colors.reset}
531
+ ${watchedDirsDisplay}
532
+ `);
533
+ });
534
+
535
+ // Handle graceful shutdown
536
+ process.on("SIGINT", () => {
537
+ console.log(`\n${colors.dim}Shutting down...${colors.reset}`);
538
+ server.close(() => {
539
+ process.exit(0);
540
+ });
541
+ });
542
+
543
+ process.on("SIGTERM", () => {
544
+ server.close(() => {
545
+ process.exit(0);
546
+ });
547
+ });
548
+ }
package/src/watcher.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @mctx-ai/mcp-dev File Watcher
3
+ *
4
+ * Watches project files for changes and triggers hot reload.
5
+ * Uses Node's built-in fs.watch with debouncing to avoid rapid reloads.
6
+ *
7
+ * Watches:
8
+ * - Project root (if package.json found)
9
+ * - Common directories: src/, lib/, utils/ (recursively)
10
+ * - Falls back to entry file directory if no package.json found
11
+ */
12
+
13
+ import { watch as fsWatch, existsSync } from "fs";
14
+ import { dirname, basename, join } from "path";
15
+
16
+ // ANSI color codes
17
+ const colors = {
18
+ reset: "\x1b[0m",
19
+ dim: "\x1b[2m",
20
+ green: "\x1b[32m",
21
+ gray: "\x1b[90m",
22
+ };
23
+
24
+ /**
25
+ * Format timestamp for logs
26
+ */
27
+ function timestamp() {
28
+ return new Date().toISOString().split("T")[1].split(".")[0];
29
+ }
30
+
31
+ /**
32
+ * Log with timestamp
33
+ */
34
+ function log(message) {
35
+ console.log(`${colors.gray}[${timestamp()}]${colors.reset} ${message}`);
36
+ }
37
+
38
+ /**
39
+ * Find project root by walking up to find package.json
40
+ */
41
+ function findProjectRoot(startPath) {
42
+ let currentPath = startPath;
43
+
44
+ // Walk up until we find package.json or reach root
45
+ while (currentPath !== dirname(currentPath)) {
46
+ const packageJsonPath = join(currentPath, "package.json");
47
+ if (existsSync(packageJsonPath)) {
48
+ return currentPath;
49
+ }
50
+ currentPath = dirname(currentPath);
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Get directories to watch
58
+ */
59
+ function getWatchDirs(entryFilePath) {
60
+ const entryDir = dirname(entryFilePath);
61
+ const projectRoot = findProjectRoot(entryDir);
62
+
63
+ if (!projectRoot) {
64
+ // No package.json found, fall back to entry file directory
65
+ return [{ path: entryDir, recursive: false }];
66
+ }
67
+
68
+ const watchDirs = [];
69
+
70
+ // Watch common directories recursively if they exist
71
+ const commonDirs = ["src", "lib", "utils"];
72
+ for (const dir of commonDirs) {
73
+ const dirPath = join(projectRoot, dir);
74
+ if (existsSync(dirPath)) {
75
+ watchDirs.push({ path: dirPath, recursive: true });
76
+ }
77
+ }
78
+
79
+ // If no common dirs found, watch project root non-recursively
80
+ if (watchDirs.length === 0) {
81
+ watchDirs.push({ path: projectRoot, recursive: false });
82
+ }
83
+
84
+ return watchDirs;
85
+ }
86
+
87
+ /**
88
+ * Watch file and directories for changes
89
+ *
90
+ * @param {string} filePath - Absolute path to file to watch
91
+ * @param {Function} onChange - Callback when file changes
92
+ * @returns {Object} Object with watchers array and watchedDirs info
93
+ *
94
+ * Watches project root and common directories (src/, lib/, utils/) recursively.
95
+ * Falls back to entry file directory if no package.json found.
96
+ */
97
+ export function watch(filePath, onChange) {
98
+ let debounceTimer = null;
99
+ const DEBOUNCE_MS = 100;
100
+
101
+ const watchDirs = getWatchDirs(filePath);
102
+ const watchers = [];
103
+
104
+ // Watch each directory
105
+ for (const { path, recursive } of watchDirs) {
106
+ const watcher = fsWatch(path, { recursive }, (eventType, changedFile) => {
107
+ // Only watch .js files
108
+ if (changedFile && !changedFile.endsWith(".js")) {
109
+ return;
110
+ }
111
+
112
+ // Debounce rapid changes (editors often write multiple times)
113
+ if (debounceTimer) {
114
+ clearTimeout(debounceTimer);
115
+ }
116
+
117
+ debounceTimer = setTimeout(() => {
118
+ log(
119
+ `${colors.green}Reloaded:${colors.reset} ${colors.dim}${changedFile || "file"}${colors.reset}`,
120
+ );
121
+ onChange();
122
+ }, DEBOUNCE_MS);
123
+ });
124
+
125
+ // Handle watcher errors
126
+ watcher.on("error", (error) => {
127
+ console.error(`Watcher error: ${error.message}`);
128
+ });
129
+
130
+ watchers.push(watcher);
131
+ }
132
+
133
+ // Clean up on process exit
134
+ process.on("exit", () => {
135
+ watchers.forEach((w) => w.close());
136
+ });
137
+
138
+ return { watchers, watchedDirs: watchDirs };
139
+ }