run-mcp 1.0.0 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +226 -1
  2. package/dist/index.js +798 -0
  3. package/package.json +41 -5
package/README.md CHANGED
@@ -1 +1,226 @@
1
- # run-mcp
1
+ # run-mcp
2
+
3
+ A smart proxy and interactive REPL for [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers.
4
+
5
+ `run-mcp` wraps any MCP server and operates in two modes:
6
+
7
+ | Mode | Audience | Purpose |
8
+ |------|----------|---------|
9
+ | **`repl`** | Humans / developers | Interactive CLI for testing and exploring MCP servers with shorthand commands |
10
+ | **`proxy`** | AI agents | Transparent MCP proxy that intercepts responses to save images to disk, enforce timeouts, and truncate massive payloads |
11
+
12
+ ## Why?
13
+
14
+ MCP servers often return large base64-encoded images (screenshots, charts) or massive JSON payloads that can blow up an AI agent's context window. `run-mcp` sits between the agent and the server, transparently:
15
+
16
+ - **Saving images to disk** instead of passing multi-MB base64 strings through
17
+ - **Enforcing timeouts** so a hung tool call doesn't block forever
18
+ - **Truncating huge text** responses to protect context budgets
19
+
20
+ For humans, the REPL mode provides a quick way to test any MCP server without writing client code.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ npm install
26
+ npm run build
27
+ ```
28
+
29
+ To install globally (makes `run-mcp` available system-wide):
30
+
31
+ ```bash
32
+ npm install -g .
33
+ ```
34
+
35
+ ## Quick Start
36
+
37
+ ### REPL Mode — Test an MCP server interactively
38
+
39
+ ```bash
40
+ # Start a REPL session with any MCP server
41
+ run-mcp repl node path/to/my-mcp-server.js
42
+
43
+ # Or use npx without installing globally
44
+ npx . repl node path/to/my-mcp-server.js
45
+ ```
46
+
47
+ You'll see an interactive prompt:
48
+
49
+ ```
50
+ ⟳ Connecting to target MCP server...
51
+ Command: node path/to/my-mcp-server.js
52
+ ✓ Connected (PID: 12345)
53
+ 5 tool(s) available. Type help for commands.
54
+
55
+ >
56
+ ```
57
+
58
+ ### Proxy Mode — Protect your agent's context
59
+
60
+ ```bash
61
+ run-mcp proxy node path/to/my-mcp-server.js --out-dir ./captured-images
62
+ ```
63
+
64
+ Then point your AI agent at `run-mcp` as the MCP server command. It transparently forwards all tools while sanitizing responses.
65
+
66
+ ## Usage
67
+
68
+ ```
69
+ run-mcp <command> [options]
70
+
71
+ Commands:
72
+ repl <target_command...> Start an interactive REPL session
73
+ proxy <target_command...> Start as a transparent MCP proxy
74
+
75
+ Options:
76
+ -V, --version Show version number
77
+ -h, --help Show help
78
+ ```
79
+
80
+ ### REPL Command
81
+
82
+ ```
83
+ run-mcp repl <target_command...> [options]
84
+
85
+ Options:
86
+ -s, --script <file> Read commands from a file instead of stdin
87
+ -o, --out-dir <path> Directory to save intercepted images (default: $TMPDIR/run-mcp)
88
+ ```
89
+
90
+ ### Proxy Command
91
+
92
+ ```
93
+ run-mcp proxy <target_command...> [options]
94
+
95
+ Options:
96
+ -o, --out-dir <path> Directory to save intercepted images (default: $TMPDIR/run-mcp)
97
+ ```
98
+
99
+ ## REPL Commands
100
+
101
+ Once in the REPL, these commands are available:
102
+
103
+ | Command | Description |
104
+ |---------|-------------|
105
+ | `tools/list` | List all tools exposed by the target server |
106
+ | `tools/describe <name>` | Show a tool's full input schema |
107
+ | `tools/call <name> <json> [--timeout <ms>]` | Call a tool with JSON arguments |
108
+ | `status` | Show target server status (PID, uptime, connection) |
109
+ | `help` | Show available commands |
110
+ | `exit` / `quit` | Disconnect and exit |
111
+
112
+ ### Examples
113
+
114
+ ```bash
115
+ # List available tools
116
+ > tools/list
117
+
118
+ # Inspect a tool's schema
119
+ > tools/describe screenshot
120
+
121
+ # Call a tool with arguments
122
+ > tools/call screenshot {"target": "#loginBtn"}
123
+
124
+ # Call with a custom timeout (5 seconds)
125
+ > tools/call long_running_tool {} --timeout 5000
126
+
127
+ # Arguments with spaces work fine
128
+ > tools/call send_message {"text": "hello world", "channel": "general"}
129
+ ```
130
+
131
+ ### Script Mode
132
+
133
+ You can automate REPL commands by writing them to a file:
134
+
135
+ ```bash
136
+ # commands.txt
137
+ tools/list
138
+ tools/call get_status {}
139
+ tools/call screenshot {"save_path": "/tmp/test.png"}
140
+ ```
141
+
142
+ ```bash
143
+ run-mcp repl node my-server.js --script commands.txt
144
+ ```
145
+
146
+ - Lines starting with `#` are treated as comments
147
+ - Exits with code `0` on success, `1` on first error
148
+
149
+ ## Proxy Mode — How It Works
150
+
151
+ In proxy mode, `run-mcp` acts as an MCP server itself. Configure it as the command your AI agent spawns:
152
+
153
+ ```json
154
+ {
155
+ "mcpServers": {
156
+ "my-server": {
157
+ "command": "run-mcp",
158
+ "args": ["proxy", "node", "path/to/actual-server.js", "--out-dir", "./images"]
159
+ }
160
+ }
161
+ }
162
+ ```
163
+
164
+ ### What the proxy intercepts
165
+
166
+ | Feature | Behavior |
167
+ |---------|----------|
168
+ | **Image extraction** | `type: "image"` responses with base64 data are saved to disk. The response is replaced with `[Image saved to /path/to/img.png (24KB)]` |
169
+ | **Base64 detection** | Text responses that are entirely base64-encoded (1000+ chars) are also saved as images |
170
+ | **Timeouts** | Tool calls are wrapped in a 60-second timeout (prevents hung calls from blocking the agent) |
171
+ | **Truncation** | Text responses exceeding 50,000 characters are truncated with a `... (truncated, N chars total)` message |
172
+
173
+ ## Architecture
174
+
175
+ ```
176
+ ┌─────────────────────┐ ┌─────────────────────┐
177
+ │ │ stdio │ │
178
+ │ AI Agent / REPL │◄───────►│ run-mcp │
179
+ │ │ │ │
180
+ └─────────────────────┘ │ ┌───────────────┐ │
181
+ │ │ Interceptor │ │
182
+ │ │ • Timeouts │ │
183
+ │ │ • Image Save │ │
184
+ │ │ • Truncation │ │
185
+ │ └───────┬───────┘ │
186
+ │ │ │
187
+ │ ┌───────▼───────┐ │
188
+ │ │ TargetManager │ │
189
+ │ │ (MCP Client) │ │
190
+ │ └───────┬───────┘ │
191
+ └──────────┼──────────┘
192
+ │ stdio
193
+ ┌──────────▼──────────┐
194
+ │ Target MCP Server │
195
+ │ (child process) │
196
+ └─────────────────────┘
197
+ ```
198
+
199
+ ### Modules
200
+
201
+ | Module | File | Responsibility |
202
+ |--------|------|----------------|
203
+ | **TargetManager** | `src/target-manager.ts` | Spawns the target MCP server, manages the MCP Client connection, captures stderr, tracks lifecycle |
204
+ | **ResponseInterceptor** | `src/interceptor.ts` | Wraps tool calls with timeouts, extracts base64 images to disk, truncates oversized text |
205
+ | **REPLMode** | `src/repl.ts` | Interactive readline REPL with shorthand command parsing and script mode |
206
+ | **ProxyMode** | `src/proxy.ts` | MCP Server that bridges `tools/list` and `tools/call` through the interceptor to the target |
207
+
208
+ ## Development
209
+
210
+ ```bash
211
+ # Install dependencies
212
+ npm install
213
+
214
+ # Build (one-time)
215
+ npm run build
216
+
217
+ # Watch mode (rebuild on changes)
218
+ npm run dev
219
+
220
+ # Run directly
221
+ node dist/index.js repl <target_command...>
222
+ ```
223
+
224
+ ## License
225
+
226
+ ISC
package/dist/index.js ADDED
@@ -0,0 +1,798 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { program } from "commander";
5
+
6
+ // src/proxy.ts
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
10
+
11
+ // src/interceptor.ts
12
+ import { mkdir, writeFile } from "fs/promises";
13
+ import { tmpdir } from "os";
14
+ import { join } from "path";
15
+ var BASE64_PATTERN = /^[A-Za-z0-9+/]{1000,}={0,2}$/;
16
+ var DEFAULT_TIMEOUT_MS = 6e4;
17
+ var MAX_TEXT_LENGTH = 5e4;
18
+ var ResponseInterceptor = class {
19
+ outDir;
20
+ defaultTimeoutMs;
21
+ fileCounter = 0;
22
+ constructor(opts = {}) {
23
+ this.outDir = opts.outDir ?? join(tmpdir(), "run-mcp");
24
+ this.defaultTimeoutMs = opts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
25
+ }
26
+ /**
27
+ * Call a tool on the target, applying timeout, image extraction, and truncation.
28
+ */
29
+ async callTool(target, name, args = {}, timeoutMs) {
30
+ const timeout = timeoutMs ?? this.defaultTimeoutMs;
31
+ const result = await Promise.race([target.callTool(name, args), this._timeout(timeout, name)]);
32
+ const content = result.content;
33
+ if (Array.isArray(content)) {
34
+ for (let i = 0; i < content.length; i++) {
35
+ content[i] = await this._processItem(content[i]);
36
+ }
37
+ }
38
+ return result;
39
+ }
40
+ /**
41
+ * Process a single content item — extract images, truncate text.
42
+ */
43
+ async _processItem(item) {
44
+ if (item.type === "image" && item.data) {
45
+ return this._saveImage(item.data, item.mimeType ?? "image/png");
46
+ }
47
+ if (item.type === "text" && item.text && BASE64_PATTERN.test(item.text.trim())) {
48
+ return this._saveImage(item.text.trim(), "image/png");
49
+ }
50
+ if (item.type === "text" && item.text && item.text.length > MAX_TEXT_LENGTH) {
51
+ const totalLength = item.text.length;
52
+ return {
53
+ type: "text",
54
+ text: item.text.slice(0, MAX_TEXT_LENGTH) + `
55
+ ... (truncated, ${totalLength.toLocaleString()} chars total)`
56
+ };
57
+ }
58
+ return item;
59
+ }
60
+ /**
61
+ * Decode base64, write to disk, return a text item with the file path.
62
+ */
63
+ async _saveImage(base64Data, mimeType) {
64
+ await mkdir(this.outDir, { recursive: true });
65
+ const ext = this._extensionFromMime(mimeType);
66
+ const timestamp = Date.now();
67
+ const counter = this.fileCounter++;
68
+ const filename = `img_${timestamp}_${counter}${ext}`;
69
+ const filepath = join(this.outDir, filename);
70
+ const buffer = Buffer.from(base64Data, "base64");
71
+ await writeFile(filepath, buffer);
72
+ const sizeKB = (buffer.length / 1024).toFixed(1);
73
+ return {
74
+ type: "text",
75
+ text: `[Image saved to ${filepath} (${sizeKB}KB)]`
76
+ };
77
+ }
78
+ /**
79
+ * Returns a promise that rejects after the given timeout.
80
+ */
81
+ _timeout(ms, toolName) {
82
+ return new Promise((_, reject) => {
83
+ setTimeout(() => {
84
+ const humanMs = ms >= 1e3 ? `${(ms / 1e3).toFixed(1)}s` : `${ms}ms`;
85
+ reject(
86
+ new Error(
87
+ `Tool "${toolName}" timed out after ${ms}ms (${humanMs}). Use --timeout <ms> to increase the limit.`
88
+ )
89
+ );
90
+ }, ms);
91
+ });
92
+ }
93
+ /**
94
+ * Map MIME type to file extension.
95
+ */
96
+ _extensionFromMime(mimeType) {
97
+ const map = {
98
+ "image/png": ".png",
99
+ "image/jpeg": ".jpg",
100
+ "image/gif": ".gif",
101
+ "image/webp": ".webp",
102
+ "image/svg+xml": ".svg",
103
+ "image/bmp": ".bmp"
104
+ };
105
+ return map[mimeType] ?? ".png";
106
+ }
107
+ };
108
+
109
+ // src/target-manager.ts
110
+ import { EventEmitter } from "events";
111
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
112
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
113
+ var MIN_UPTIME_FOR_RESTART_MS = 5e3;
114
+ var MAX_RECONNECT_ATTEMPTS = 3;
115
+ var STABLE_CONNECTION_RESET_MS = 6e4;
116
+ var TargetManager = class _TargetManager extends EventEmitter {
117
+ constructor(command, args) {
118
+ super();
119
+ this.command = command;
120
+ this.args = args;
121
+ }
122
+ client = null;
123
+ transport = null;
124
+ startTime = 0;
125
+ childPid = null;
126
+ _connected = false;
127
+ // Enhanced status tracking
128
+ _lastResponseTime = null;
129
+ _stderrLineCount = 0;
130
+ // Auto-reconnect state
131
+ _reconnectAttempts = 0;
132
+ _stableTimer = null;
133
+ _autoReconnect = false;
134
+ _reconnecting = false;
135
+ _intentionalClose = false;
136
+ /**
137
+ * Enable auto-reconnect behavior.
138
+ * Only applies to interactive REPL mode — proxy mode manages its own lifecycle.
139
+ */
140
+ enableAutoReconnect() {
141
+ this._autoReconnect = true;
142
+ }
143
+ /**
144
+ * Spawn the target MCP server and establish the MCP client connection.
145
+ * Stderr from the child process is emitted as 'stderr' events.
146
+ */
147
+ async connect() {
148
+ this.transport = new StdioClientTransport({
149
+ command: this.command,
150
+ args: this.args,
151
+ stderr: "pipe"
152
+ });
153
+ this.transport.stderr?.on("data", (chunk) => {
154
+ const text = chunk.toString().trimEnd();
155
+ if (text) {
156
+ this._stderrLineCount += text.split("\n").length;
157
+ this.emit("stderr", text);
158
+ }
159
+ });
160
+ this.client = new Client({ name: "run-mcp", version: "1.1.0" }, { capabilities: {} });
161
+ this.client.onclose = () => {
162
+ this._connected = false;
163
+ this._clearStableTimer();
164
+ if (this._intentionalClose) {
165
+ return;
166
+ }
167
+ this.emit("disconnected");
168
+ this._maybeReconnect();
169
+ };
170
+ await this.client.connect(this.transport);
171
+ this._connected = true;
172
+ this.startTime = Date.now();
173
+ const proc = this.transport._process;
174
+ if (proc?.pid) {
175
+ this.childPid = proc.pid;
176
+ }
177
+ this.emit("connected");
178
+ this._registerCleanup();
179
+ this._startStableTimer();
180
+ }
181
+ get connected() {
182
+ return this._connected;
183
+ }
184
+ /**
185
+ * Record that a response was received (for status tracking).
186
+ */
187
+ recordResponse() {
188
+ this._lastResponseTime = Date.now();
189
+ }
190
+ /**
191
+ * List all tools exposed by the target MCP server.
192
+ */
193
+ async listTools() {
194
+ this._assertConnected();
195
+ const result = await this.client.listTools();
196
+ this.recordResponse();
197
+ return result;
198
+ }
199
+ /**
200
+ * Call a tool on the target MCP server.
201
+ */
202
+ async callTool(name, args = {}) {
203
+ this._assertConnected();
204
+ const result = await this.client.callTool({ name, arguments: args });
205
+ this.recordResponse();
206
+ return result;
207
+ }
208
+ /**
209
+ * Returns current connection status, PID, uptime, and diagnostics.
210
+ */
211
+ getStatus() {
212
+ return {
213
+ pid: this.childPid,
214
+ uptime: this._connected ? (Date.now() - this.startTime) / 1e3 : 0,
215
+ connected: this._connected,
216
+ command: this.command,
217
+ args: this.args,
218
+ lastResponseTime: this._lastResponseTime,
219
+ stderrLineCount: this._stderrLineCount,
220
+ reconnectAttempts: this._reconnectAttempts,
221
+ maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS
222
+ };
223
+ }
224
+ /**
225
+ * Cleanly shut down the client connection and child process.
226
+ */
227
+ async close() {
228
+ this._intentionalClose = true;
229
+ this._clearStableTimer();
230
+ if (this.client) {
231
+ try {
232
+ await this.client.close();
233
+ } catch {
234
+ }
235
+ this.client = null;
236
+ }
237
+ if (this.transport) {
238
+ try {
239
+ await this.transport.close();
240
+ } catch {
241
+ }
242
+ this.transport = null;
243
+ }
244
+ this._connected = false;
245
+ this.childPid = null;
246
+ }
247
+ // ─── Auto-reconnect logic ──────────────────────────────────────────────────
248
+ /**
249
+ * Decide whether to attempt auto-reconnect after a disconnect.
250
+ *
251
+ * Rules:
252
+ * 1. Auto-reconnect must be enabled
253
+ * 2. Server must have been alive for ≥5s (otherwise it's a startup bug)
254
+ * 3. Must not exceed MAX_RECONNECT_ATTEMPTS consecutive retries
255
+ * 4. Must not already be reconnecting
256
+ */
257
+ async _maybeReconnect() {
258
+ if (!this._autoReconnect || this._reconnecting) return;
259
+ const uptimeMs = Date.now() - this.startTime;
260
+ if (uptimeMs < MIN_UPTIME_FOR_RESTART_MS) {
261
+ this.emit("reconnect_failed", {
262
+ reason: "startup_crash",
263
+ message: `Server crashed after ${(uptimeMs / 1e3).toFixed(1)}s \u2014 too soon to be a transient failure (min ${MIN_UPTIME_FOR_RESTART_MS / 1e3}s). Not retrying.`
264
+ });
265
+ return;
266
+ }
267
+ if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
268
+ this.emit("reconnect_failed", {
269
+ reason: "max_retries",
270
+ message: `Server has crashed ${this._reconnectAttempts} times in a row. Giving up.`
271
+ });
272
+ return;
273
+ }
274
+ this._reconnecting = true;
275
+ this._reconnectAttempts++;
276
+ this.emit("reconnecting", {
277
+ attempt: this._reconnectAttempts,
278
+ maxAttempts: MAX_RECONNECT_ATTEMPTS
279
+ });
280
+ this.client = null;
281
+ this.transport = null;
282
+ this.childPid = null;
283
+ try {
284
+ await this.connect();
285
+ this.emit("reconnected", { attempt: this._reconnectAttempts });
286
+ } catch (err) {
287
+ this.emit("reconnect_failed", {
288
+ reason: "connect_error",
289
+ message: `Reconnect attempt ${this._reconnectAttempts} failed: ${err.message}`
290
+ });
291
+ } finally {
292
+ this._reconnecting = false;
293
+ }
294
+ }
295
+ /**
296
+ * After STABLE_CONNECTION_RESET_MS of being connected, reset the retry counter.
297
+ * This way, a server that crashes once after 10 minutes of stability
298
+ * gets a fresh set of retries.
299
+ */
300
+ _startStableTimer() {
301
+ this._clearStableTimer();
302
+ this._stableTimer = setTimeout(() => {
303
+ if (this._connected) {
304
+ this._reconnectAttempts = 0;
305
+ }
306
+ }, STABLE_CONNECTION_RESET_MS);
307
+ }
308
+ _clearStableTimer() {
309
+ if (this._stableTimer) {
310
+ clearTimeout(this._stableTimer);
311
+ this._stableTimer = null;
312
+ }
313
+ }
314
+ // ─── Internal helpers ──────────────────────────────────────────────────────
315
+ _assertConnected() {
316
+ if (!this._connected || !this.client) {
317
+ throw new Error("Not connected to target MCP server");
318
+ }
319
+ }
320
+ static _cleanupRegistered = false;
321
+ static _instances = /* @__PURE__ */ new Set();
322
+ _registerCleanup() {
323
+ _TargetManager._instances.add(this);
324
+ if (_TargetManager._cleanupRegistered) return;
325
+ _TargetManager._cleanupRegistered = true;
326
+ const cleanupAll = () => {
327
+ for (const instance of _TargetManager._instances) {
328
+ instance.close().catch(() => {
329
+ });
330
+ }
331
+ };
332
+ process.on("exit", cleanupAll);
333
+ process.on("SIGINT", () => {
334
+ cleanupAll();
335
+ process.exit(130);
336
+ });
337
+ process.on("SIGTERM", () => {
338
+ cleanupAll();
339
+ process.exit(143);
340
+ });
341
+ }
342
+ };
343
+
344
+ // src/proxy.ts
345
+ async function startProxy(targetCommand, opts) {
346
+ const [command, ...args] = targetCommand;
347
+ const target = new TargetManager(command, args);
348
+ const interceptor = new ResponseInterceptor({ outDir: opts.outDir });
349
+ target.on("stderr", (text) => {
350
+ process.stderr.write(`[target] ${text}
351
+ `);
352
+ });
353
+ process.stderr.write("[proxy] Connecting to target MCP server...\n");
354
+ try {
355
+ await target.connect();
356
+ } catch (err) {
357
+ process.stderr.write(`[proxy] Failed to connect to target: ${err.message}
358
+ `);
359
+ process.exit(1);
360
+ }
361
+ const status = target.getStatus();
362
+ process.stderr.write(`[proxy] Connected to target (PID: ${status.pid})
363
+ `);
364
+ const mcpServer = new McpServer(
365
+ {
366
+ name: "run-mcp-proxy",
367
+ version: "1.1.0"
368
+ },
369
+ { capabilities: { tools: {} } }
370
+ );
371
+ const server = mcpServer.server;
372
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
373
+ const result = await target.listTools();
374
+ return { tools: result.tools };
375
+ });
376
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
377
+ const { name, arguments: toolArgs } = request.params;
378
+ try {
379
+ const result = await interceptor.callTool(
380
+ target,
381
+ name,
382
+ toolArgs ?? {}
383
+ );
384
+ const content = (result.content ?? []).map((item) => {
385
+ if (item.type === "image") {
386
+ return { type: "image", data: item.data, mimeType: item.mimeType };
387
+ }
388
+ return { type: "text", text: String(item.text ?? "") };
389
+ });
390
+ return { content };
391
+ } catch (err) {
392
+ return {
393
+ content: [{ type: "text", text: `Error: ${err.message}` }],
394
+ isError: true
395
+ };
396
+ }
397
+ });
398
+ const transport = new StdioServerTransport();
399
+ server.onclose = async () => {
400
+ process.stderr.write("[proxy] Parent disconnected, shutting down...\n");
401
+ await target.close();
402
+ process.exit(0);
403
+ };
404
+ await mcpServer.connect(transport);
405
+ process.stderr.write("[proxy] Proxy server running on stdio.\n");
406
+ target.on("disconnected", () => {
407
+ process.stderr.write("[proxy] Target server disconnected.\n");
408
+ process.exit(1);
409
+ });
410
+ }
411
+
412
+ // src/repl.ts
413
+ import { readFile } from "fs/promises";
414
+ import { createInterface } from "readline";
415
+ import pc from "picocolors";
416
+
417
+ // src/parsing.ts
418
+ function parseCommandLine(input) {
419
+ const spaceIdx = input.indexOf(" ");
420
+ if (spaceIdx === -1) {
421
+ return { cmd: input.toLowerCase(), rest: "" };
422
+ }
423
+ return {
424
+ cmd: input.slice(0, spaceIdx).toLowerCase(),
425
+ rest: input.slice(spaceIdx + 1)
426
+ };
427
+ }
428
+ function parseCallArgs(rest) {
429
+ const trimmed = rest.trim();
430
+ if (!trimmed) return { toolName: "", jsonArgs: "" };
431
+ const spaceIdx = trimmed.indexOf(" ");
432
+ if (spaceIdx === -1) {
433
+ return { toolName: trimmed, jsonArgs: "" };
434
+ }
435
+ const toolName = trimmed.slice(0, spaceIdx);
436
+ let remainder = trimmed.slice(spaceIdx + 1).trim();
437
+ let timeoutMs;
438
+ const timeoutMatch = remainder.match(/\s--timeout\s+(\d+)\s*$/);
439
+ if (timeoutMatch) {
440
+ timeoutMs = parseInt(timeoutMatch[1], 10);
441
+ remainder = remainder.slice(0, timeoutMatch.index).trim();
442
+ }
443
+ return { toolName, jsonArgs: remainder, timeoutMs };
444
+ }
445
+ function formatJson(obj, indent = 2) {
446
+ const json = JSON.stringify(obj, null, indent);
447
+ return json.split("\n").map((line) => " ".repeat(indent) + line).join("\n");
448
+ }
449
+ function levenshtein(a, b) {
450
+ const m = a.length;
451
+ const n = b.length;
452
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
453
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
454
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
455
+ for (let i = 1; i <= m; i++) {
456
+ for (let j = 1; j <= n; j++) {
457
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
458
+ }
459
+ }
460
+ return dp[m][n];
461
+ }
462
+ function suggestCommand(input, commands, threshold = 0.4) {
463
+ let best = null;
464
+ let bestDist = Infinity;
465
+ for (const cmd of commands) {
466
+ const dist = levenshtein(input, cmd);
467
+ if (dist < bestDist) {
468
+ bestDist = dist;
469
+ best = cmd;
470
+ }
471
+ }
472
+ if (best && bestDist <= Math.ceil(input.length * threshold)) {
473
+ return best;
474
+ }
475
+ return null;
476
+ }
477
+
478
+ // src/repl.ts
479
+ var KNOWN_COMMANDS = [
480
+ "tools/list",
481
+ "tools/describe",
482
+ "tools/call",
483
+ "status",
484
+ "help",
485
+ "exit",
486
+ "quit"
487
+ ];
488
+ async function startRepl(targetCommand, opts) {
489
+ const [command, ...args] = targetCommand;
490
+ const target = new TargetManager(command, args);
491
+ const interceptor = new ResponseInterceptor({ outDir: opts.outDir });
492
+ target.on("stderr", (text) => {
493
+ for (const line of text.split("\n")) {
494
+ console.error(pc.dim(`[server] ${line}`));
495
+ }
496
+ });
497
+ console.log(pc.cyan("\u27F3 Connecting to target MCP server..."));
498
+ console.log(pc.dim(` Command: ${targetCommand.join(" ")}`));
499
+ try {
500
+ await target.connect();
501
+ } catch (err) {
502
+ const msg = err.message ?? String(err);
503
+ if (msg.includes("ENOENT") || msg.includes("spawn")) {
504
+ console.error(pc.red(`\u2717 Failed to start server: command "${command}" not found.`));
505
+ console.error(pc.dim(` Check that "${command}" is installed and in your PATH.`));
506
+ } else {
507
+ console.error(pc.red(`\u2717 Failed to connect: ${msg}`));
508
+ console.error(pc.dim(` Check that the target command starts a valid MCP server on stdio.`));
509
+ }
510
+ process.exit(1);
511
+ }
512
+ const status = target.getStatus();
513
+ console.log(pc.green(`\u2713 Connected (PID: ${status.pid})`));
514
+ if (!opts.script) {
515
+ target.enableAutoReconnect();
516
+ target.on(
517
+ "reconnecting",
518
+ ({ attempt, maxAttempts }) => {
519
+ console.log(
520
+ pc.yellow(`
521
+ \u27F3 Server disconnected. Reconnecting (${attempt}/${maxAttempts})...`)
522
+ );
523
+ }
524
+ );
525
+ target.on("reconnected", ({ attempt }) => {
526
+ const s = target.getStatus();
527
+ console.log(pc.green(`\u2713 Reconnected (PID: ${s.pid}, attempt ${attempt})`));
528
+ });
529
+ target.on("reconnect_failed", ({ reason, message }) => {
530
+ console.error(pc.red(`\u2717 ${message}`));
531
+ if (reason === "max_retries") {
532
+ console.log(
533
+ pc.dim(" Use 'exit' to quit or wait for the server to be fixed and restart manually.")
534
+ );
535
+ }
536
+ });
537
+ }
538
+ try {
539
+ const { tools } = await target.listTools();
540
+ console.log(
541
+ pc.cyan(` ${tools.length} tool(s) available. Type ${pc.bold("help")} for commands.
542
+ `)
543
+ );
544
+ } catch (err) {
545
+ console.log(pc.yellow(` Warning: Could not list tools: ${err.message}
546
+ `));
547
+ }
548
+ const isScript = !!opts.script;
549
+ if (isScript) {
550
+ const lines = await readScriptLines(opts.script);
551
+ for (const line of lines) {
552
+ const trimmed = line.trim();
553
+ if (!trimmed || trimmed.startsWith("#")) continue;
554
+ try {
555
+ await handleCommand(trimmed, target, interceptor);
556
+ } catch (err) {
557
+ console.error(pc.red(`\u2717 Error: ${err.message}`));
558
+ console.log(pc.dim("\nShutting down..."));
559
+ await target.close();
560
+ process.exit(1);
561
+ }
562
+ }
563
+ console.log(pc.dim("\nShutting down..."));
564
+ await target.close();
565
+ process.exit(0);
566
+ } else {
567
+ const rl = createInterface({
568
+ input: process.stdin,
569
+ output: process.stdout,
570
+ prompt: pc.cyan("> "),
571
+ terminal: true
572
+ });
573
+ rl.prompt();
574
+ let processing = false;
575
+ const queue = [];
576
+ const processQueue = async () => {
577
+ if (processing) return;
578
+ processing = true;
579
+ while (queue.length > 0) {
580
+ const trimmed = queue.shift();
581
+ try {
582
+ await handleCommand(trimmed, target, interceptor);
583
+ } catch (err) {
584
+ console.error(pc.red(`\u2717 Error: ${err.message}`));
585
+ }
586
+ rl.prompt();
587
+ }
588
+ processing = false;
589
+ };
590
+ rl.on("line", (line) => {
591
+ const trimmed = line.trim();
592
+ if (!trimmed || trimmed.startsWith("#")) {
593
+ rl.prompt();
594
+ return;
595
+ }
596
+ queue.push(trimmed);
597
+ processQueue();
598
+ });
599
+ rl.on("close", async () => {
600
+ console.log(pc.dim("\nShutting down..."));
601
+ await target.close();
602
+ process.exit(0);
603
+ });
604
+ }
605
+ }
606
+ async function handleCommand(input, target, interceptor) {
607
+ const { cmd, rest } = parseCommandLine(input);
608
+ switch (cmd) {
609
+ case "help":
610
+ printHelp();
611
+ return;
612
+ case "tools/list":
613
+ await cmdToolsList(target);
614
+ return;
615
+ case "tools/describe":
616
+ await cmdToolsDescribe(target, rest);
617
+ return;
618
+ case "tools/call":
619
+ await cmdToolsCall(target, interceptor, rest);
620
+ return;
621
+ case "status":
622
+ cmdStatus(target);
623
+ return;
624
+ case "exit":
625
+ case "quit":
626
+ process.emit("SIGINT", "SIGINT");
627
+ return;
628
+ default: {
629
+ const suggestion = suggestCommand(cmd, KNOWN_COMMANDS);
630
+ if (suggestion) {
631
+ console.log(pc.yellow(`Unknown command: ${cmd}. Did you mean ${pc.bold(suggestion)}?`));
632
+ } else {
633
+ console.log(pc.yellow(`Unknown command: ${cmd}. Type ${pc.bold("help")} for usage.`));
634
+ }
635
+ }
636
+ }
637
+ }
638
+ async function cmdToolsList(target) {
639
+ const { tools } = await target.listTools();
640
+ if (tools.length === 0) {
641
+ console.log(pc.dim(" No tools available."));
642
+ return;
643
+ }
644
+ const nameWidth = Math.max(8, ...tools.map((t) => t.name.length));
645
+ console.log(pc.bold(` ${"Name".padEnd(nameWidth)} Description`));
646
+ console.log(pc.dim(` ${"\u2500".repeat(nameWidth)} ${"\u2500".repeat(50)}`));
647
+ for (const tool of tools) {
648
+ const desc = tool.description ? tool.description.length > 60 ? `${tool.description.slice(0, 57)}...` : tool.description : pc.dim("(no description)");
649
+ console.log(` ${pc.green(tool.name.padEnd(nameWidth))} ${desc}`);
650
+ }
651
+ console.log(pc.dim(`
652
+ ${tools.length} tool(s) total.`));
653
+ }
654
+ async function cmdToolsDescribe(target, rest) {
655
+ const name = rest.trim();
656
+ if (!name) {
657
+ console.log(pc.yellow("Usage: tools/describe <tool_name>"));
658
+ return;
659
+ }
660
+ const { tools } = await target.listTools();
661
+ const tool = tools.find((t) => t.name === name);
662
+ if (!tool) {
663
+ console.log(pc.red(`Tool "${name}" not found.`));
664
+ console.log(pc.dim(`Available: ${tools.map((t) => t.name).join(", ")}`));
665
+ return;
666
+ }
667
+ console.log(pc.bold(`
668
+ ${tool.name}`));
669
+ if (tool.description) {
670
+ console.log(pc.dim(` ${tool.description}`));
671
+ }
672
+ console.log(pc.cyan("\n Input Schema:"));
673
+ console.log(formatJson(tool.inputSchema, 4));
674
+ }
675
+ async function cmdToolsCall(target, interceptor, rest) {
676
+ const { toolName, jsonArgs, timeoutMs } = parseCallArgs(rest);
677
+ if (!toolName) {
678
+ console.log(pc.yellow("Usage: tools/call <tool_name> [json_args] [--timeout <ms>]"));
679
+ return;
680
+ }
681
+ let args = {};
682
+ if (jsonArgs) {
683
+ try {
684
+ args = JSON.parse(jsonArgs);
685
+ } catch (err) {
686
+ console.error(pc.red(`Invalid JSON: ${err.message}`));
687
+ console.log(pc.dim(` Received: ${jsonArgs}`));
688
+ return;
689
+ }
690
+ }
691
+ console.log(pc.dim(` Calling ${toolName}...`));
692
+ const startTime = Date.now();
693
+ const result = await interceptor.callTool(target, toolName, args, timeoutMs);
694
+ const elapsed = Date.now() - startTime;
695
+ const content = result.content;
696
+ if (Array.isArray(content)) {
697
+ for (const item of content) {
698
+ if (item.type === "text") {
699
+ console.log(item.text);
700
+ } else {
701
+ console.log(formatJson(item, 2));
702
+ }
703
+ }
704
+ } else {
705
+ console.log(formatJson(result, 2));
706
+ }
707
+ console.log(pc.dim(` (${elapsed}ms)`));
708
+ }
709
+ function cmdStatus(target) {
710
+ const s = target.getStatus();
711
+ const uptimeStr = s.uptime >= 60 ? `${Math.floor(s.uptime / 60)}m ${(s.uptime % 60).toFixed(0)}s` : `${s.uptime.toFixed(1)}s`;
712
+ const lastRespStr = s.lastResponseTime ? `${((Date.now() - s.lastResponseTime) / 1e3).toFixed(1)}s ago` : "never";
713
+ console.log(pc.bold("\n Target Server Status"));
714
+ console.log(` ${pc.dim("Connected:")} ${s.connected ? pc.green("yes") : pc.red("no")}`);
715
+ console.log(` ${pc.dim("PID:")} ${s.pid ?? "N/A"}`);
716
+ console.log(` ${pc.dim("Uptime:")} ${uptimeStr}`);
717
+ console.log(` ${pc.dim("Last response:")} ${lastRespStr}`);
718
+ console.log(` ${pc.dim("Stderr lines:")} ${s.stderrLineCount.toLocaleString()}`);
719
+ console.log(` ${pc.dim("Reconnects:")} ${s.reconnectAttempts}/${s.maxReconnectAttempts}`);
720
+ console.log(` ${pc.dim("Command:")} ${s.command} ${s.args.join(" ")}`);
721
+ console.log();
722
+ }
723
+ function printHelp() {
724
+ console.log(`
725
+ ${pc.bold("Available Commands:")}
726
+
727
+ ${pc.green("tools/list")} List all available tools
728
+ ${pc.green("tools/describe")} <name> Show a tool's input schema
729
+ ${pc.green("tools/call")} <name> <json> [opts] Call a tool with JSON arguments
730
+ Options: ${pc.dim("--timeout <ms>")} Override default timeout (60s)
731
+ ${pc.green("status")} Show target server status
732
+ ${pc.green("help")} Show this help
733
+ ${pc.green("exit")} / ${pc.green("quit")} Disconnect and exit
734
+
735
+ ${pc.dim("Lines starting with # are treated as comments.")}
736
+ ${pc.dim('JSON arguments can contain spaces: tools/call say {"message": "hello world"}')}
737
+ `);
738
+ }
739
+ async function readScriptLines(filepath) {
740
+ const content = await readFile(filepath, "utf-8");
741
+ return content.split("\n");
742
+ }
743
+
744
+ // src/index.ts
745
+ program.name("run-mcp").enablePositionalOptions().description(
746
+ "A smart proxy and interactive REPL for Model Context Protocol (MCP) servers.\n\nOperates in two modes:\n repl - Human-friendly CLI for testing MCP servers interactively\n proxy - Transparent MCP proxy that intercepts images, enforces timeouts,\n and truncates large payloads to protect an AI agent's context window"
747
+ ).version("1.1.0").addHelpText(
748
+ "after",
749
+ `
750
+ Examples:
751
+ $ run-mcp repl node my-server.js # Interactive testing
752
+ $ run-mcp repl node my-server.js -s test.txt # Run a script
753
+ $ run-mcp proxy node my-server.js # Proxy for AI agents
754
+ $ run-mcp repl npx -y some-mcp-server # Test an npx server
755
+
756
+ Run 'run-mcp <command> --help' for detailed options.`
757
+ );
758
+ if (process.argv.length <= 2) {
759
+ program.outputHelp();
760
+ process.exit(0);
761
+ }
762
+ program.command("repl").description("Start an interactive REPL session with a target MCP server").passThroughOptions().allowUnknownOption().argument("<target_command...>", "Command to spawn the target MCP server").option("-s, --script <file>", "Read commands from a file instead of stdin").option("-o, --out-dir <path>", "Directory to save intercepted images").addHelpText(
763
+ "after",
764
+ `
765
+ Examples:
766
+ $ run-mcp repl node my-server.js
767
+ $ run-mcp repl node my-server.js --script verify.txt
768
+ $ run-mcp repl node my-server.js --out-dir ./screenshots
769
+
770
+ REPL Commands (once connected):
771
+ tools/list List all available tools
772
+ tools/describe <name> Show a tool's input schema
773
+ tools/call <name> <json> [opts] Call a tool with JSON arguments
774
+ status Show target server status
775
+ help Show all commands`
776
+ ).action(async (targetCommand, opts) => {
777
+ await startRepl(targetCommand, opts);
778
+ });
779
+ program.command("proxy").description("Start as a transparent MCP proxy between an AI agent and a target server").passThroughOptions().allowUnknownOption().argument("<target_command...>", "Command to spawn the target MCP server").option("-o, --out-dir <path>", "Directory to save intercepted images").addHelpText(
780
+ "after",
781
+ `
782
+ Examples:
783
+ $ run-mcp proxy node my-server.js
784
+ $ run-mcp proxy node my-server.js --out-dir ./images
785
+
786
+ Use this in your MCP client configuration to wrap any MCP server:
787
+ {
788
+ "mcpServers": {
789
+ "my-server": {
790
+ "command": "run-mcp",
791
+ "args": ["proxy", "node", "my-server.js"]
792
+ }
793
+ }
794
+ }`
795
+ ).action(async (targetCommand, opts) => {
796
+ await startProxy(targetCommand, opts);
797
+ });
798
+ program.parse();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "run-mcp",
3
- "version": "1.0.0",
4
- "description": "",
3
+ "version": "1.1.0",
4
+ "description": "A smart proxy and interactive REPL for Model Context Protocol (MCP) servers",
5
5
  "homepage": "https://github.com/funkyfunc/run-mcp#readme",
6
6
  "bugs": {
7
7
  "url": "https://github.com/funkyfunc/run-mcp/issues"
@@ -12,9 +12,45 @@
12
12
  },
13
13
  "license": "ISC",
14
14
  "author": "",
15
- "type": "commonjs",
16
- "main": "index.js",
15
+ "type": "module",
16
+ "main": "dist/index.js",
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "keywords": [
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "proxy",
24
+ "repl",
25
+ "cli"
26
+ ],
27
+ "bin": {
28
+ "run-mcp": "dist/index.js"
29
+ },
17
30
  "scripts": {
18
- "test": "echo \"Error: no test specified\" && exit 1"
31
+ "build": "tsup",
32
+ "dev": "tsup --watch",
33
+ "start": "node dist/index.js",
34
+ "typecheck": "tsc --noEmit",
35
+ "lint": "biome check",
36
+ "lint:fix": "biome check --write",
37
+ "format": "biome format --write",
38
+ "pretest": "tsup",
39
+ "test": "vitest run",
40
+ "prepublishOnly": "tsup"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.12.1",
44
+ "commander": "^13.1.0",
45
+ "picocolors": "^1.1.1",
46
+ "zod": "^3.24.3"
47
+ },
48
+ "devDependencies": {
49
+ "@biomejs/biome": "^2.4.10",
50
+ "@types/node": "^22.13.14",
51
+ "tsup": "^8.5.1",
52
+ "tsx": "^4.21.0",
53
+ "typescript": "^5.8.2",
54
+ "vitest": "^4.1.2"
19
55
  }
20
56
  }