run-mcp 1.1.0 → 1.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.
Files changed (3) hide show
  1. package/README.md +70 -10
  2. package/dist/index.js +726 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # run-mcp
2
2
 
3
- A smart proxy and interactive REPL for [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers.
3
+ A smart proxy, interactive REPL, and live test harness for [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers.
4
4
 
5
- `run-mcp` wraps any MCP server and operates in two modes:
5
+ `run-mcp` wraps any MCP server and operates in three modes:
6
6
 
7
7
  | Mode | Audience | Purpose |
8
8
  |------|----------|---------|
9
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 |
10
+ | **`proxy`** | AI agents (transparent) | Transparent MCP proxy that intercepts responses to save images to disk, enforce timeouts, and truncate massive payloads |
11
+ | **`server`** | AI agents (explicit) | MCP server that lets agents dynamically connect to, inspect, and test local MCP servers |
11
12
 
12
13
  ## Why?
13
14
 
@@ -93,8 +94,49 @@ Options:
93
94
  run-mcp proxy <target_command...> [options]
94
95
 
95
96
  Options:
96
- -o, --out-dir <path> Directory to save intercepted images (default: $TMPDIR/run-mcp)
97
+ -o, --out-dir <path> Directory to save intercepted images and audio (default: $TMPDIR/run-mcp)
98
+ -t, --timeout <ms> Default tool call timeout in milliseconds (default: 60000)
99
+ --max-text <chars> Max text response length before truncation (default: 50000)
100
+ ```
101
+
102
+ ### Server Command
103
+
97
104
  ```
105
+ run-mcp server [options]
106
+
107
+ Options:
108
+ -o, --out-dir <path> Directory to save intercepted images and audio (default: $TMPDIR/run-mcp)
109
+ -t, --timeout <ms> Default tool call timeout in milliseconds (default: 60000)
110
+ --max-text <chars> Max text response length before truncation (default: 50000)
111
+ ```
112
+
113
+ Add to your MCP client configuration:
114
+
115
+ ```json
116
+ {
117
+ "mcpServers": {
118
+ "run-mcp": {
119
+ "command": "npx",
120
+ "args": ["-y", "run-mcp", "server"]
121
+ }
122
+ }
123
+ }
124
+ ```
125
+
126
+ Then use these tools from your agent:
127
+
128
+ | Tool | Description |
129
+ |------|-------------|
130
+ | `connect_to_mcp` | Spawn and connect to a local MCP server |
131
+ | `disconnect_from_mcp` | Tear down the connection |
132
+ | `mcp_server_status` | Check connection status |
133
+ | `list_mcp_tools` | List tools on the connected server |
134
+ | `call_mcp_tool` | Call a tool (with interception) |
135
+ | `list_mcp_resources` | List resources |
136
+ | `read_mcp_resource` | Read a resource by URI |
137
+ | `list_mcp_prompts` | List prompts |
138
+ | `get_mcp_prompt` | Get a prompt by name |
139
+ | `get_mcp_server_stderr` | View target server stderr output |
98
140
 
99
141
  ## REPL Commands
100
142
 
@@ -161,14 +203,32 @@ In proxy mode, `run-mcp` acts as an MCP server itself. Configure it as the comma
161
203
  }
162
204
  ```
163
205
 
206
+ ### What the proxy forwards
207
+
208
+ The proxy dynamically mirrors the target server's capabilities. All MCP primitives that the target supports are forwarded transparently:
209
+
210
+ | Primitive | Forwarded? |
211
+ |-----------|------------|
212
+ | **Tools** (`tools/list`, `tools/call`) | ✅ Always (with interception) |
213
+ | **Resources** (`resources/list`, `resources/read`, `resources/templates/list`) | ✅ If target supports |
214
+ | **Prompts** (`prompts/list`, `prompts/get`) | ✅ If target supports |
215
+ | **Logging** (`logging/setLevel`) | ✅ If target supports |
216
+ | **Completion** (`completion/complete`) | ✅ If target supports |
217
+ | **Notifications** (list changes, logging) | ✅ Forwarded from target to agent |
218
+ | **Tool annotations** (`readOnlyHint`, `destructiveHint`, etc.) | ✅ Preserved as-is |
219
+ | **Pagination** (`nextCursor` / `cursor`) | ✅ Passed through |
220
+
164
221
  ### What the proxy intercepts
165
222
 
223
+ Tool call responses are processed through the interceptor pipeline. All other primitives pass through untouched.
224
+
166
225
  | Feature | Behavior |
167
226
  |---------|----------|
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)]` |
227
+ | **Image extraction** | `type: "image"` responses with base64 data are saved to disk. Replaced with `[Image saved to /path/to/img.png (24KB)]` |
228
+ | **Audio extraction** | `type: "audio"` responses with base64 data are saved to disk. Replaced with `[Audio saved to /path/to/audio.wav (12KB)]` |
169
229
  | **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 |
230
+ | **Timeouts** | Tool calls are wrapped in a configurable timeout (default 60s, use `--timeout` to change) |
231
+ | **Truncation** | Text responses exceeding the limit (default 50K chars, use `--max-text` to change) are truncated |
172
232
 
173
233
  ## Architecture
174
234
 
@@ -200,10 +260,10 @@ In proxy mode, `run-mcp` acts as an MCP server itself. Configure it as the comma
200
260
 
201
261
  | Module | File | Responsibility |
202
262
  |--------|------|----------------|
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 |
263
+ | **TargetManager** | `src/target-manager.ts` | Spawns the target MCP server, manages the MCP Client connection, forwards all MCP primitives (tools, resources, prompts, logging), captures stderr, tracks lifecycle |
264
+ | **ResponseInterceptor** | `src/interceptor.ts` | Wraps tool calls with timeouts, extracts base64 images and audio to disk, truncates oversized text |
205
265
  | **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 |
266
+ | **ProxyMode** | `src/proxy.ts` | MCP Server that transparently forwards all MCP primitives to the target, with tool responses running through the interceptor |
207
267
 
208
268
  ## Development
209
269
 
package/dist/index.js CHANGED
@@ -6,7 +6,23 @@ import { program } from "commander";
6
6
  // src/proxy.ts
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
9
+ import {
10
+ CallToolRequestSchema,
11
+ CompleteRequestSchema,
12
+ GetPromptRequestSchema,
13
+ ListPromptsRequestSchema,
14
+ ListResourcesRequestSchema,
15
+ ListResourceTemplatesRequestSchema,
16
+ ListToolsRequestSchema,
17
+ LoggingMessageNotificationSchema,
18
+ PromptListChangedNotificationSchema,
19
+ ReadResourceRequestSchema,
20
+ ResourceListChangedNotificationSchema,
21
+ SetLevelRequestSchema,
22
+ SubscribeRequestSchema,
23
+ ToolListChangedNotificationSchema,
24
+ UnsubscribeRequestSchema
25
+ } from "@modelcontextprotocol/sdk/types.js";
10
26
 
11
27
  // src/interceptor.ts
12
28
  import { mkdir, writeFile } from "fs/promises";
@@ -14,17 +30,22 @@ import { tmpdir } from "os";
14
30
  import { join } from "path";
15
31
  var BASE64_PATTERN = /^[A-Za-z0-9+/]{1000,}={0,2}$/;
16
32
  var DEFAULT_TIMEOUT_MS = 6e4;
17
- var MAX_TEXT_LENGTH = 5e4;
33
+ var DEFAULT_MAX_TEXT_LENGTH = 5e4;
18
34
  var ResponseInterceptor = class {
19
35
  outDir;
20
36
  defaultTimeoutMs;
37
+ maxTextLength;
21
38
  fileCounter = 0;
22
39
  constructor(opts = {}) {
23
40
  this.outDir = opts.outDir ?? join(tmpdir(), "run-mcp");
24
41
  this.defaultTimeoutMs = opts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
42
+ this.maxTextLength = opts.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH;
25
43
  }
26
44
  /**
27
- * Call a tool on the target, applying timeout, image extraction, and truncation.
45
+ * Call a tool on the target, applying timeout, media extraction, and truncation.
46
+ *
47
+ * Returns the full result object as-is (including structuredContent, isError, _meta)
48
+ * with only the content array items modified when interception is needed.
28
49
  */
29
50
  async callTool(target, name, args = {}, timeoutMs) {
30
51
  const timeout = timeoutMs ?? this.defaultTimeoutMs;
@@ -38,20 +59,25 @@ var ResponseInterceptor = class {
38
59
  return result;
39
60
  }
40
61
  /**
41
- * Process a single content item — extract images, truncate text.
62
+ * Process a single content item — extract media, truncate text.
63
+ * Preserves all item properties not related to the intercepted data
64
+ * (e.g., annotations, _meta).
42
65
  */
43
66
  async _processItem(item) {
44
67
  if (item.type === "image" && item.data) {
45
- return this._saveImage(item.data, item.mimeType ?? "image/png");
68
+ return this._saveMedia(item.data, item.mimeType ?? "image/png", "image");
69
+ }
70
+ if (item.type === "audio" && item.data) {
71
+ return this._saveMedia(item.data, item.mimeType ?? "audio/wav", "audio");
46
72
  }
47
73
  if (item.type === "text" && item.text && BASE64_PATTERN.test(item.text.trim())) {
48
- return this._saveImage(item.text.trim(), "image/png");
74
+ return this._saveMedia(item.text.trim(), "image/png", "image");
49
75
  }
50
- if (item.type === "text" && item.text && item.text.length > MAX_TEXT_LENGTH) {
76
+ if (item.type === "text" && item.text && item.text.length > this.maxTextLength) {
51
77
  const totalLength = item.text.length;
52
78
  return {
53
- type: "text",
54
- text: item.text.slice(0, MAX_TEXT_LENGTH) + `
79
+ ...item,
80
+ text: item.text.slice(0, this.maxTextLength) + `
55
81
  ... (truncated, ${totalLength.toLocaleString()} chars total)`
56
82
  };
57
83
  }
@@ -59,20 +85,23 @@ var ResponseInterceptor = class {
59
85
  }
60
86
  /**
61
87
  * Decode base64, write to disk, return a text item with the file path.
88
+ * Works for both images and audio.
62
89
  */
63
- async _saveImage(base64Data, mimeType) {
90
+ async _saveMedia(base64Data, mimeType, mediaType) {
64
91
  await mkdir(this.outDir, { recursive: true });
65
92
  const ext = this._extensionFromMime(mimeType);
66
93
  const timestamp = Date.now();
67
94
  const counter = this.fileCounter++;
68
- const filename = `img_${timestamp}_${counter}${ext}`;
95
+ const prefix = mediaType === "audio" ? "audio" : "img";
96
+ const filename = `${prefix}_${timestamp}_${counter}${ext}`;
69
97
  const filepath = join(this.outDir, filename);
70
98
  const buffer = Buffer.from(base64Data, "base64");
71
99
  await writeFile(filepath, buffer);
72
100
  const sizeKB = (buffer.length / 1024).toFixed(1);
101
+ const label = mediaType === "audio" ? "Audio" : "Image";
73
102
  return {
74
103
  type: "text",
75
- text: `[Image saved to ${filepath} (${sizeKB}KB)]`
104
+ text: `[${label} saved to ${filepath} (${sizeKB}KB)]`
76
105
  };
77
106
  }
78
107
  /**
@@ -92,17 +121,28 @@ var ResponseInterceptor = class {
92
121
  }
93
122
  /**
94
123
  * Map MIME type to file extension.
124
+ * Covers image and audio types.
95
125
  */
96
126
  _extensionFromMime(mimeType) {
97
127
  const map = {
128
+ // Images
98
129
  "image/png": ".png",
99
130
  "image/jpeg": ".jpg",
100
131
  "image/gif": ".gif",
101
132
  "image/webp": ".webp",
102
133
  "image/svg+xml": ".svg",
103
- "image/bmp": ".bmp"
134
+ "image/bmp": ".bmp",
135
+ // Audio
136
+ "audio/wav": ".wav",
137
+ "audio/mpeg": ".mp3",
138
+ "audio/mp3": ".mp3",
139
+ "audio/ogg": ".ogg",
140
+ "audio/flac": ".flac",
141
+ "audio/aac": ".aac",
142
+ "audio/webm": ".webm",
143
+ "audio/mp4": ".m4a"
104
144
  };
105
- return map[mimeType] ?? ".png";
145
+ return map[mimeType] ?? (mimeType.startsWith("audio/") ? ".wav" : ".png");
106
146
  }
107
147
  };
108
148
 
@@ -127,6 +167,8 @@ var TargetManager = class _TargetManager extends EventEmitter {
127
167
  // Enhanced status tracking
128
168
  _lastResponseTime = null;
129
169
  _stderrLineCount = 0;
170
+ _stderrLines = [];
171
+ static MAX_STDERR_LINES = 200;
130
172
  // Auto-reconnect state
131
173
  _reconnectAttempts = 0;
132
174
  _stableTimer = null;
@@ -153,11 +195,16 @@ var TargetManager = class _TargetManager extends EventEmitter {
153
195
  this.transport.stderr?.on("data", (chunk) => {
154
196
  const text = chunk.toString().trimEnd();
155
197
  if (text) {
156
- this._stderrLineCount += text.split("\n").length;
198
+ const lines = text.split("\n");
199
+ this._stderrLineCount += lines.length;
200
+ this._stderrLines.push(...lines);
201
+ if (this._stderrLines.length > _TargetManager.MAX_STDERR_LINES) {
202
+ this._stderrLines = this._stderrLines.slice(-_TargetManager.MAX_STDERR_LINES);
203
+ }
157
204
  this.emit("stderr", text);
158
205
  }
159
206
  });
160
- this.client = new Client({ name: "run-mcp", version: "1.1.0" }, { capabilities: {} });
207
+ this.client = new Client({ name: "run-mcp", version: "1.3.0" }, { capabilities: {} });
161
208
  this.client.onclose = () => {
162
209
  this._connected = false;
163
210
  this._clearStableTimer();
@@ -187,12 +234,29 @@ var TargetManager = class _TargetManager extends EventEmitter {
187
234
  recordResponse() {
188
235
  this._lastResponseTime = Date.now();
189
236
  }
237
+ // ─── Server introspection ───────────────────────────────────────────────────
238
+ /**
239
+ * Returns the target server's advertised capabilities.
240
+ * Available after connect() completes.
241
+ */
242
+ getServerCapabilities() {
243
+ return this.client?.getServerCapabilities();
244
+ }
245
+ /**
246
+ * Returns the target server's instructions string (if any).
247
+ * Agents may use this for system prompts or behavioral hints.
248
+ */
249
+ getInstructions() {
250
+ return this.client?.getInstructions();
251
+ }
252
+ // ─── Tools ──────────────────────────────────────────────────────────────────
190
253
  /**
191
254
  * List all tools exposed by the target MCP server.
255
+ * Supports cursor-based pagination via params.
192
256
  */
193
- async listTools() {
257
+ async listTools(params) {
194
258
  this._assertConnected();
195
- const result = await this.client.listTools();
259
+ const result = await this.client.listTools(params);
196
260
  this.recordResponse();
197
261
  return result;
198
262
  }
@@ -205,6 +269,112 @@ var TargetManager = class _TargetManager extends EventEmitter {
205
269
  this.recordResponse();
206
270
  return result;
207
271
  }
272
+ // ─── Resources ──────────────────────────────────────────────────────────────
273
+ /**
274
+ * List resources exposed by the target MCP server.
275
+ * Supports cursor-based pagination.
276
+ */
277
+ async listResources(params) {
278
+ this._assertConnected();
279
+ const result = await this.client.listResources(params);
280
+ this.recordResponse();
281
+ return result;
282
+ }
283
+ /**
284
+ * List resource templates exposed by the target MCP server.
285
+ * Supports cursor-based pagination.
286
+ */
287
+ async listResourceTemplates(params) {
288
+ this._assertConnected();
289
+ const result = await this.client.listResourceTemplates(params);
290
+ this.recordResponse();
291
+ return result;
292
+ }
293
+ /**
294
+ * Read a specific resource by URI from the target MCP server.
295
+ */
296
+ async readResource(params) {
297
+ this._assertConnected();
298
+ const result = await this.client.readResource(params);
299
+ this.recordResponse();
300
+ return result;
301
+ }
302
+ /**
303
+ * Subscribe to resource updates on the target MCP server.
304
+ */
305
+ async subscribeResource(params) {
306
+ this._assertConnected();
307
+ const result = await this.client.subscribeResource(params);
308
+ this.recordResponse();
309
+ return result;
310
+ }
311
+ /**
312
+ * Unsubscribe from resource updates on the target MCP server.
313
+ */
314
+ async unsubscribeResource(params) {
315
+ this._assertConnected();
316
+ const result = await this.client.unsubscribeResource(params);
317
+ this.recordResponse();
318
+ return result;
319
+ }
320
+ // ─── Prompts ────────────────────────────────────────────────────────────────
321
+ /**
322
+ * List prompts exposed by the target MCP server.
323
+ * Supports cursor-based pagination.
324
+ */
325
+ async listPrompts(params) {
326
+ this._assertConnected();
327
+ const result = await this.client.listPrompts(params);
328
+ this.recordResponse();
329
+ return result;
330
+ }
331
+ /**
332
+ * Get a specific prompt by name from the target MCP server.
333
+ */
334
+ async getPrompt(params) {
335
+ this._assertConnected();
336
+ const result = await this.client.getPrompt(params);
337
+ this.recordResponse();
338
+ return result;
339
+ }
340
+ // ─── Logging ────────────────────────────────────────────────────────────────
341
+ /**
342
+ * Set the logging level on the target MCP server.
343
+ */
344
+ async setLoggingLevel(level) {
345
+ this._assertConnected();
346
+ const result = await this.client.setLoggingLevel(level);
347
+ this.recordResponse();
348
+ return result;
349
+ }
350
+ // ─── Completion ─────────────────────────────────────────────────────────────
351
+ /**
352
+ * Request completion from the target MCP server (for autocomplete UX).
353
+ */
354
+ async complete(params) {
355
+ this._assertConnected();
356
+ const result = await this.client.complete(params);
357
+ this.recordResponse();
358
+ return result;
359
+ }
360
+ // ─── Notification forwarding ────────────────────────────────────────────────
361
+ /**
362
+ * Access the underlying MCP client for advanced use cases like
363
+ * subscribing to notifications with proper SDK schemas.
364
+ * Prefer the typed methods above when possible.
365
+ */
366
+ getRawClient() {
367
+ return this.client;
368
+ }
369
+ // ─── Status & lifecycle ─────────────────────────────────────────────────────
370
+ /**
371
+ * Returns the last N lines of stderr output from the target server.
372
+ * Useful for debugging crashes or unexpected behavior.
373
+ */
374
+ getStderrLines(count) {
375
+ if (!count || count >= this._stderrLines.length) return [...this._stderrLines];
376
+ return this._stderrLines.slice(-count);
377
+ }
208
378
  /**
209
379
  * Returns current connection status, PID, uptime, and diagnostics.
210
380
  */
@@ -345,7 +515,11 @@ var TargetManager = class _TargetManager extends EventEmitter {
345
515
  async function startProxy(targetCommand, opts) {
346
516
  const [command, ...args] = targetCommand;
347
517
  const target = new TargetManager(command, args);
348
- const interceptor = new ResponseInterceptor({ outDir: opts.outDir });
518
+ const interceptor = new ResponseInterceptor({
519
+ outDir: opts.outDir,
520
+ defaultTimeoutMs: opts.timeoutMs,
521
+ maxTextLength: opts.maxTextLength
522
+ });
349
523
  target.on("stderr", (text) => {
350
524
  process.stderr.write(`[target] ${text}
351
525
  `);
@@ -361,17 +535,33 @@ async function startProxy(targetCommand, opts) {
361
535
  const status = target.getStatus();
362
536
  process.stderr.write(`[proxy] Connected to target (PID: ${status.pid})
363
537
  `);
538
+ const targetCaps = target.getServerCapabilities() ?? {};
539
+ const proxyCaps = {};
540
+ proxyCaps.tools = targetCaps.tools ?? {};
541
+ if (targetCaps.resources) proxyCaps.resources = targetCaps.resources;
542
+ if (targetCaps.prompts) proxyCaps.prompts = targetCaps.prompts;
543
+ if (targetCaps.logging) proxyCaps.logging = targetCaps.logging;
544
+ if (targetCaps.completions) proxyCaps.completions = targetCaps.completions;
545
+ process.stderr.write(`[proxy] Mirroring capabilities: ${Object.keys(proxyCaps).join(", ")}
546
+ `);
547
+ const instructions = target.getInstructions();
548
+ if (instructions) {
549
+ process.stderr.write(
550
+ `[proxy] Target instructions: ${instructions.slice(0, 200)}${instructions.length > 200 ? "..." : ""}
551
+ `
552
+ );
553
+ }
364
554
  const mcpServer = new McpServer(
365
555
  {
366
556
  name: "run-mcp-proxy",
367
- version: "1.1.0"
557
+ version: "1.3.0"
368
558
  },
369
- { capabilities: { tools: {} } }
559
+ { capabilities: proxyCaps }
370
560
  );
371
561
  const server = mcpServer.server;
372
- server.setRequestHandler(ListToolsRequestSchema, async () => {
373
- const result = await target.listTools();
374
- return { tools: result.tools };
562
+ server.setRequestHandler(ListToolsRequestSchema, async (request) => {
563
+ const result = await target.listTools(request.params);
564
+ return result;
375
565
  });
376
566
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
377
567
  const { name, arguments: toolArgs } = request.params;
@@ -381,13 +571,7 @@ async function startProxy(targetCommand, opts) {
381
571
  name,
382
572
  toolArgs ?? {}
383
573
  );
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 };
574
+ return result;
391
575
  } catch (err) {
392
576
  return {
393
577
  content: [{ type: "text", text: `Error: ${err.message}` }],
@@ -395,6 +579,69 @@ async function startProxy(targetCommand, opts) {
395
579
  };
396
580
  }
397
581
  });
582
+ if (targetCaps.resources) {
583
+ server.setRequestHandler(ListResourcesRequestSchema, async (request) => {
584
+ return await target.listResources(request.params);
585
+ });
586
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async (request) => {
587
+ return await target.listResourceTemplates(request.params);
588
+ });
589
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
590
+ return await target.readResource(request.params);
591
+ });
592
+ if (targetCaps.resources.subscribe) {
593
+ server.setRequestHandler(SubscribeRequestSchema, async (request) => {
594
+ return await target.subscribeResource(request.params);
595
+ });
596
+ server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
597
+ return await target.unsubscribeResource(request.params);
598
+ });
599
+ }
600
+ }
601
+ if (targetCaps.prompts) {
602
+ server.setRequestHandler(ListPromptsRequestSchema, async (request) => {
603
+ return await target.listPrompts(request.params);
604
+ });
605
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
606
+ return await target.getPrompt(request.params);
607
+ });
608
+ }
609
+ if (targetCaps.logging) {
610
+ server.setRequestHandler(SetLevelRequestSchema, async (request) => {
611
+ return await target.setLoggingLevel(request.params.level);
612
+ });
613
+ }
614
+ if (targetCaps.completions) {
615
+ server.setRequestHandler(CompleteRequestSchema, async (request) => {
616
+ return await target.complete(request.params);
617
+ });
618
+ }
619
+ const rawClient = target.getRawClient();
620
+ if (rawClient) {
621
+ if (targetCaps.tools && targetCaps.tools.listChanged) {
622
+ rawClient.setNotificationHandler(ToolListChangedNotificationSchema, () => {
623
+ server.notification({ method: "notifications/tools/list_changed" });
624
+ });
625
+ }
626
+ if (targetCaps.resources && targetCaps.resources.listChanged) {
627
+ rawClient.setNotificationHandler(ResourceListChangedNotificationSchema, () => {
628
+ server.notification({ method: "notifications/resources/list_changed" });
629
+ });
630
+ }
631
+ if (targetCaps.prompts && targetCaps.prompts.listChanged) {
632
+ rawClient.setNotificationHandler(PromptListChangedNotificationSchema, () => {
633
+ server.notification({ method: "notifications/prompts/list_changed" });
634
+ });
635
+ }
636
+ if (targetCaps.logging) {
637
+ rawClient.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
638
+ server.notification({
639
+ method: "notifications/message",
640
+ params: notification.params
641
+ });
642
+ });
643
+ }
644
+ }
398
645
  const transport = new StdioServerTransport();
399
646
  server.onclose = async () => {
400
647
  process.stderr.write("[proxy] Parent disconnected, shutting down...\n");
@@ -741,16 +988,419 @@ async function readScriptLines(filepath) {
741
988
  return content.split("\n");
742
989
  }
743
990
 
991
+ // src/server.ts
992
+ import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
993
+ import { StdioServerTransport as StdioServerTransport2 } from "@modelcontextprotocol/sdk/server/stdio.js";
994
+ import { z } from "zod";
995
+ async function startServer(opts) {
996
+ let target = null;
997
+ const interceptor = new ResponseInterceptor({
998
+ outDir: opts.outDir,
999
+ defaultTimeoutMs: opts.timeoutMs,
1000
+ maxTextLength: opts.maxTextLength
1001
+ });
1002
+ const mcpServer = new McpServer2(
1003
+ { name: "run-mcp", version: "1.3.0" },
1004
+ {
1005
+ capabilities: {
1006
+ tools: {}
1007
+ }
1008
+ }
1009
+ );
1010
+ mcpServer.registerTool(
1011
+ "connect_to_mcp",
1012
+ {
1013
+ title: "Connect to MCP Server",
1014
+ description: "Spawn and connect to a local MCP server process. Use this to test an MCP server you're building. Only one connection at a time \u2014 call disconnect_from_mcp first if already connected.",
1015
+ inputSchema: {
1016
+ command: z.string().describe("Command to run (e.g. 'node', 'python', 'npx')"),
1017
+ args: z.array(z.string()).optional().describe("Arguments to pass (e.g. ['src/index.js'] or ['-y', 'some-server'])"),
1018
+ env: z.record(z.string()).optional().describe("Extra environment variables for the child process")
1019
+ }
1020
+ },
1021
+ async ({ command, args, env }) => {
1022
+ if (target?.connected) {
1023
+ return {
1024
+ content: [
1025
+ {
1026
+ type: "text",
1027
+ text: "Already connected to a target server. Call disconnect_from_mcp first, then connect again."
1028
+ }
1029
+ ],
1030
+ isError: true
1031
+ };
1032
+ }
1033
+ if (target) {
1034
+ await target.close();
1035
+ target = null;
1036
+ }
1037
+ try {
1038
+ if (env) {
1039
+ for (const [key, value] of Object.entries(env)) {
1040
+ process.env[key] = value;
1041
+ }
1042
+ }
1043
+ target = new TargetManager(command, args ?? []);
1044
+ await target.connect();
1045
+ const status = target.getStatus();
1046
+ const caps = target.getServerCapabilities() ?? {};
1047
+ const capSummary = [];
1048
+ if (caps.tools) capSummary.push("tools");
1049
+ if (caps.resources) capSummary.push("resources");
1050
+ if (caps.prompts) capSummary.push("prompts");
1051
+ if (caps.logging) capSummary.push("logging");
1052
+ let toolCount = 0;
1053
+ try {
1054
+ const tools = await target.listTools();
1055
+ toolCount = tools.tools.length;
1056
+ } catch {
1057
+ }
1058
+ const lines = [
1059
+ `Connected to MCP server (PID: ${status.pid})`,
1060
+ `Command: ${command} ${(args ?? []).join(" ")}`,
1061
+ `Capabilities: ${capSummary.join(", ") || "none"}`,
1062
+ `Tools available: ${toolCount}`,
1063
+ "",
1064
+ "Use list_mcp_tools, call_mcp_tool, list_mcp_resources, etc. to interact with it.",
1065
+ "Use disconnect_from_mcp when done, or to reconnect after code changes."
1066
+ ];
1067
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1068
+ } catch (err) {
1069
+ target = null;
1070
+ return {
1071
+ content: [
1072
+ {
1073
+ type: "text",
1074
+ text: `Failed to connect: ${err.message}
1075
+
1076
+ Check that the command is correct and the server starts without errors. You can also check get_mcp_server_stderr after a failed connect for more details.`
1077
+ }
1078
+ ],
1079
+ isError: true
1080
+ };
1081
+ }
1082
+ }
1083
+ );
1084
+ mcpServer.registerTool(
1085
+ "disconnect_from_mcp",
1086
+ {
1087
+ title: "Disconnect from MCP Server",
1088
+ description: "Tear down the current MCP server connection. Call this before reconnecting after code changes."
1089
+ },
1090
+ async () => {
1091
+ if (!target) {
1092
+ return {
1093
+ content: [{ type: "text", text: "No target server is connected." }],
1094
+ isError: true
1095
+ };
1096
+ }
1097
+ const status = target.getStatus();
1098
+ await target.close();
1099
+ target = null;
1100
+ return {
1101
+ content: [
1102
+ {
1103
+ type: "text",
1104
+ text: `Disconnected from MCP server (was PID: ${status.pid}, uptime: ${status.uptime.toFixed(1)}s).`
1105
+ }
1106
+ ]
1107
+ };
1108
+ }
1109
+ );
1110
+ mcpServer.registerTool(
1111
+ "mcp_server_status",
1112
+ {
1113
+ title: "MCP Server Status",
1114
+ description: "Check the current target server connection status, PID, uptime, and capabilities."
1115
+ },
1116
+ async () => {
1117
+ if (!target) {
1118
+ return {
1119
+ content: [
1120
+ {
1121
+ type: "text",
1122
+ text: "No target server connected. Use connect_to_mcp to connect to one."
1123
+ }
1124
+ ]
1125
+ };
1126
+ }
1127
+ const status = target.getStatus();
1128
+ const caps = target.getServerCapabilities() ?? {};
1129
+ const lines = [
1130
+ `Connected: ${status.connected}`,
1131
+ `PID: ${status.pid}`,
1132
+ `Uptime: ${status.uptime.toFixed(1)}s`,
1133
+ `Command: ${status.command} ${status.args.join(" ")}`,
1134
+ `Capabilities: ${Object.keys(caps).join(", ") || "none"}`,
1135
+ `Stderr lines: ${status.stderrLineCount}`,
1136
+ `Last response: ${status.lastResponseTime ? new Date(status.lastResponseTime).toISOString() : "none"}`
1137
+ ];
1138
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1139
+ }
1140
+ );
1141
+ mcpServer.registerTool(
1142
+ "list_mcp_tools",
1143
+ {
1144
+ title: "List MCP Tools",
1145
+ description: "List all tools exposed by the connected MCP server, including descriptions, input schemas, and annotations."
1146
+ },
1147
+ async () => {
1148
+ if (!target?.connected) {
1149
+ return {
1150
+ content: [
1151
+ {
1152
+ type: "text",
1153
+ text: "No target server connected. Use connect_to_mcp first."
1154
+ }
1155
+ ],
1156
+ isError: true
1157
+ };
1158
+ }
1159
+ try {
1160
+ const result = await target.listTools();
1161
+ return {
1162
+ content: [
1163
+ {
1164
+ type: "text",
1165
+ text: JSON.stringify(result.tools, null, 2)
1166
+ }
1167
+ ]
1168
+ };
1169
+ } catch (err) {
1170
+ return {
1171
+ content: [{ type: "text", text: `Error listing tools: ${err.message}` }],
1172
+ isError: true
1173
+ };
1174
+ }
1175
+ }
1176
+ );
1177
+ mcpServer.registerTool(
1178
+ "call_mcp_tool",
1179
+ {
1180
+ title: "Call MCP Tool",
1181
+ description: "Call a tool on the connected MCP server. Responses go through the interceptor: images/audio are saved to disk, timeouts are enforced, and oversized text is truncated.",
1182
+ inputSchema: {
1183
+ name: z.string().describe("Name of the tool to call"),
1184
+ arguments: z.record(z.unknown()).optional().describe("Arguments to pass to the tool (as a JSON object)"),
1185
+ timeout_ms: z.number().optional().describe("Timeout for this specific call in milliseconds (overrides default)")
1186
+ }
1187
+ },
1188
+ async ({ name, arguments: toolArgs, timeout_ms }) => {
1189
+ if (!target?.connected) {
1190
+ return {
1191
+ content: [
1192
+ {
1193
+ type: "text",
1194
+ text: "No target server connected. Use connect_to_mcp first."
1195
+ }
1196
+ ],
1197
+ isError: true
1198
+ };
1199
+ }
1200
+ try {
1201
+ const result = await interceptor.callTool(
1202
+ target,
1203
+ name,
1204
+ toolArgs ?? {},
1205
+ timeout_ms
1206
+ );
1207
+ return result;
1208
+ } catch (err) {
1209
+ return {
1210
+ content: [{ type: "text", text: `Error: ${err.message}` }],
1211
+ isError: true
1212
+ };
1213
+ }
1214
+ }
1215
+ );
1216
+ mcpServer.registerTool(
1217
+ "list_mcp_resources",
1218
+ {
1219
+ title: "List MCP Resources",
1220
+ description: "List all resources exposed by the connected MCP server."
1221
+ },
1222
+ async () => {
1223
+ if (!target?.connected) {
1224
+ return {
1225
+ content: [
1226
+ {
1227
+ type: "text",
1228
+ text: "No target server connected. Use connect_to_mcp first."
1229
+ }
1230
+ ],
1231
+ isError: true
1232
+ };
1233
+ }
1234
+ try {
1235
+ const result = await target.listResources();
1236
+ return {
1237
+ content: [{ type: "text", text: JSON.stringify(result.resources, null, 2) }]
1238
+ };
1239
+ } catch (err) {
1240
+ return {
1241
+ content: [{ type: "text", text: `Error listing resources: ${err.message}` }],
1242
+ isError: true
1243
+ };
1244
+ }
1245
+ }
1246
+ );
1247
+ mcpServer.registerTool(
1248
+ "read_mcp_resource",
1249
+ {
1250
+ title: "Read MCP Resource",
1251
+ description: "Read a specific resource by URI from the connected MCP server.",
1252
+ inputSchema: {
1253
+ uri: z.string().describe("URI of the resource to read (e.g. 'docs://readme')")
1254
+ }
1255
+ },
1256
+ async ({ uri }) => {
1257
+ if (!target?.connected) {
1258
+ return {
1259
+ content: [
1260
+ {
1261
+ type: "text",
1262
+ text: "No target server connected. Use connect_to_mcp first."
1263
+ }
1264
+ ],
1265
+ isError: true
1266
+ };
1267
+ }
1268
+ try {
1269
+ const result = await target.readResource({ uri });
1270
+ return {
1271
+ content: [{ type: "text", text: JSON.stringify(result.contents, null, 2) }]
1272
+ };
1273
+ } catch (err) {
1274
+ return {
1275
+ content: [{ type: "text", text: `Error reading resource: ${err.message}` }],
1276
+ isError: true
1277
+ };
1278
+ }
1279
+ }
1280
+ );
1281
+ mcpServer.registerTool(
1282
+ "list_mcp_prompts",
1283
+ {
1284
+ title: "List MCP Prompts",
1285
+ description: "List all prompts exposed by the connected MCP server."
1286
+ },
1287
+ async () => {
1288
+ if (!target?.connected) {
1289
+ return {
1290
+ content: [
1291
+ {
1292
+ type: "text",
1293
+ text: "No target server connected. Use connect_to_mcp first."
1294
+ }
1295
+ ],
1296
+ isError: true
1297
+ };
1298
+ }
1299
+ try {
1300
+ const result = await target.listPrompts();
1301
+ return {
1302
+ content: [{ type: "text", text: JSON.stringify(result.prompts, null, 2) }]
1303
+ };
1304
+ } catch (err) {
1305
+ return {
1306
+ content: [{ type: "text", text: `Error listing prompts: ${err.message}` }],
1307
+ isError: true
1308
+ };
1309
+ }
1310
+ }
1311
+ );
1312
+ mcpServer.registerTool(
1313
+ "get_mcp_prompt",
1314
+ {
1315
+ title: "Get MCP Prompt",
1316
+ description: "Get a specific prompt by name from the connected MCP server.",
1317
+ inputSchema: {
1318
+ name: z.string().describe("Name of the prompt"),
1319
+ arguments: z.record(z.string()).optional().describe("Arguments to pass to the prompt")
1320
+ }
1321
+ },
1322
+ async ({ name, arguments: promptArgs }) => {
1323
+ if (!target?.connected) {
1324
+ return {
1325
+ content: [
1326
+ {
1327
+ type: "text",
1328
+ text: "No target server connected. Use connect_to_mcp first."
1329
+ }
1330
+ ],
1331
+ isError: true
1332
+ };
1333
+ }
1334
+ try {
1335
+ const result = await target.getPrompt({
1336
+ name,
1337
+ arguments: promptArgs ?? {}
1338
+ });
1339
+ return {
1340
+ content: [{ type: "text", text: JSON.stringify(result.messages, null, 2) }]
1341
+ };
1342
+ } catch (err) {
1343
+ return {
1344
+ content: [{ type: "text", text: `Error getting prompt: ${err.message}` }],
1345
+ isError: true
1346
+ };
1347
+ }
1348
+ }
1349
+ );
1350
+ mcpServer.registerTool(
1351
+ "get_mcp_server_stderr",
1352
+ {
1353
+ title: "Get MCP Server Stderr",
1354
+ description: "Get recent stderr output from the target MCP server. Useful for debugging crashes, startup failures, or unexpected behavior.",
1355
+ inputSchema: {
1356
+ lines: z.number().optional().describe("Number of recent lines to return (default: all, max 200)")
1357
+ }
1358
+ },
1359
+ async ({ lines }) => {
1360
+ if (!target) {
1361
+ return {
1362
+ content: [
1363
+ {
1364
+ type: "text",
1365
+ text: "No target server (current or previous). Nothing to show."
1366
+ }
1367
+ ]
1368
+ };
1369
+ }
1370
+ const stderrLines = target.getStderrLines(lines);
1371
+ if (stderrLines.length === 0) {
1372
+ return {
1373
+ content: [{ type: "text", text: "No stderr output captured." }]
1374
+ };
1375
+ }
1376
+ return {
1377
+ content: [{ type: "text", text: stderrLines.join("\n") }]
1378
+ };
1379
+ }
1380
+ );
1381
+ const transport = new StdioServerTransport2();
1382
+ mcpServer.server.onclose = async () => {
1383
+ if (target) {
1384
+ await target.close();
1385
+ }
1386
+ process.exit(0);
1387
+ };
1388
+ await mcpServer.connect(transport);
1389
+ process.stderr.write("[server] run-mcp test harness running on stdio.\n");
1390
+ process.stderr.write("[server] Waiting for connect_to_mcp call...\n");
1391
+ }
1392
+
744
1393
  // src/index.ts
745
1394
  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(
1395
+ "A smart proxy, interactive REPL, and live test harness for MCP servers.\n\nOperates in three 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\n server - MCP server that lets AI agents dynamically test local MCP servers"
1396
+ ).version("1.3.0").addHelpText(
748
1397
  "after",
749
1398
  `
750
1399
  Examples:
751
- $ run-mcp repl node my-server.js # Interactive testing
1400
+ $ run-mcp repl node my-server.js # Interactive testing (human)
752
1401
  $ 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
1402
+ $ run-mcp proxy node my-server.js # Transparent proxy (agent)
1403
+ $ run-mcp server # Test harness (agent)
754
1404
  $ run-mcp repl npx -y some-mcp-server # Test an npx server
755
1405
 
756
1406
  Run 'run-mcp <command> --help' for detailed options.`
@@ -776,12 +1426,14 @@ REPL Commands (once connected):
776
1426
  ).action(async (targetCommand, opts) => {
777
1427
  await startRepl(targetCommand, opts);
778
1428
  });
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(
1429
+ 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 and audio").option("-t, --timeout <ms>", "Default tool call timeout in milliseconds (default: 60000)").option("--max-text <chars>", "Max text response length before truncation (default: 50000)").addHelpText(
780
1430
  "after",
781
1431
  `
782
1432
  Examples:
783
1433
  $ run-mcp proxy node my-server.js
784
1434
  $ run-mcp proxy node my-server.js --out-dir ./images
1435
+ $ run-mcp proxy node my-server.js --timeout 120000
1436
+ $ run-mcp proxy node my-server.js --max-text 100000
785
1437
 
786
1438
  Use this in your MCP client configuration to wrap any MCP server:
787
1439
  {
@@ -792,7 +1444,43 @@ Use this in your MCP client configuration to wrap any MCP server:
792
1444
  }
793
1445
  }
794
1446
  }`
795
- ).action(async (targetCommand, opts) => {
796
- await startProxy(targetCommand, opts);
1447
+ ).action(
1448
+ async (targetCommand, opts) => {
1449
+ await startProxy(targetCommand, {
1450
+ outDir: opts.outDir,
1451
+ timeoutMs: opts.timeout ? Number.parseInt(opts.timeout, 10) : void 0,
1452
+ maxTextLength: opts.maxText ? Number.parseInt(opts.maxText, 10) : void 0
1453
+ });
1454
+ }
1455
+ );
1456
+ program.command("server").description("Start as an MCP server that lets AI agents dynamically test local MCP servers").option("-o, --out-dir <path>", "Directory to save intercepted images and audio").option("-t, --timeout <ms>", "Default tool call timeout in milliseconds (default: 60000)").option("--max-text <chars>", "Max text response length before truncation (default: 50000)").addHelpText(
1457
+ "after",
1458
+ `
1459
+ Examples:
1460
+ $ run-mcp server
1461
+ $ run-mcp server --out-dir ./test-output
1462
+ $ run-mcp server --timeout 120000
1463
+
1464
+ Add to your MCP client configuration:
1465
+ {
1466
+ "mcpServers": {
1467
+ "run-mcp": {
1468
+ "command": "npx",
1469
+ "args": ["-y", "run-mcp", "server"]
1470
+ }
1471
+ }
1472
+ }
1473
+
1474
+ Then use these tools from your agent:
1475
+ connect_to_mcp \u2192 Spawn and connect to a local MCP server
1476
+ list_mcp_tools \u2192 List tools on the connected server
1477
+ call_mcp_tool \u2192 Call a tool (with interception)
1478
+ disconnect_from_mcp \u2192 Tear down and reconnect after changes`
1479
+ ).action(async (opts) => {
1480
+ await startServer({
1481
+ outDir: opts.outDir,
1482
+ timeoutMs: opts.timeout ? Number.parseInt(opts.timeout, 10) : void 0,
1483
+ maxTextLength: opts.maxText ? Number.parseInt(opts.maxText, 10) : void 0
1484
+ });
797
1485
  });
798
1486
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
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": {