@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 +21 -0
- package/package.json +41 -0
- package/src/cli.js +104 -0
- package/src/server.js +548 -0
- package/src/watcher.js +139 -0
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
|
+
}
|