run-mcp 1.2.0 → 1.3.2

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 +37 -19
  2. package/dist/index.js +565 -259
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,17 +1,15 @@
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` operates in two modes:
6
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 |
7
+ 1. **Interactive REPL** (`run-mcp repl`) A headless CLI for human developers to manually test and explore MCP servers using short, memorable commands (`tools/call`, `status`, etc.).
8
+ 2. **Server Mode** (`run-mcp server`) — An MCP server that exposes tools (`connect_to_mcp`, `call_mcp_tool`) so AI agents can dynamically connect to and test local MCP projects without hardcoding them in configuration files.
11
9
 
12
- ## Why?
10
+ ### Interception Rules (Server Mode & REPL)
13
11
 
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:
12
+ To protect the CLI and parent agents from large payloads, `run-mcp` automatically applies the following rules:
15
13
 
16
14
  - **Saving images to disk** instead of passing multi-MB base64 strings through
17
15
  - **Enforcing timeouts** so a hung tool call doesn't block forever
@@ -55,14 +53,6 @@ You'll see an interactive prompt:
55
53
  >
56
54
  ```
57
55
 
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
56
  ## Usage
67
57
 
68
58
  ```
@@ -70,7 +60,7 @@ run-mcp <command> [options]
70
60
 
71
61
  Commands:
72
62
  repl <target_command...> Start an interactive REPL session
73
- proxy <target_command...> Start as a transparent MCP proxy
63
+ server Start as an MCP server for agents
74
64
 
75
65
  Options:
76
66
  -V, --version Show version number
@@ -87,10 +77,10 @@ Options:
87
77
  -o, --out-dir <path> Directory to save intercepted images (default: $TMPDIR/run-mcp)
88
78
  ```
89
79
 
90
- ### Proxy Command
80
+ ### Server Command
91
81
 
92
82
  ```
93
- run-mcp proxy <target_command...> [options]
83
+ run-mcp server [options]
94
84
 
95
85
  Options:
96
86
  -o, --out-dir <path> Directory to save intercepted images and audio (default: $TMPDIR/run-mcp)
@@ -98,6 +88,34 @@ Options:
98
88
  --max-text <chars> Max text response length before truncation (default: 50000)
99
89
  ```
100
90
 
91
+ Add to your MCP client configuration:
92
+
93
+ ```json
94
+ {
95
+ "mcpServers": {
96
+ "run-mcp": {
97
+ "command": "npx",
98
+ "args": ["-y", "run-mcp", "server"]
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ Then use these tools from your agent:
105
+
106
+ | Tool | Description |
107
+ |------|-------------|
108
+ | `connect_to_mcp` | Spawn and connect to a local MCP server |
109
+ | `disconnect_from_mcp` | Tear down the connection |
110
+ | `mcp_server_status` | Check connection status |
111
+ | `list_mcp_tools` | List tools on the connected server |
112
+ | `call_mcp_tool` | Call a tool (with interception) |
113
+ | `list_mcp_resources` | List resources |
114
+ | `read_mcp_resource` | Read a resource by URI |
115
+ | `list_mcp_prompts` | List prompts |
116
+ | `get_mcp_prompt` | Get a prompt by name |
117
+ | `get_mcp_server_stderr` | View target server stderr output |
118
+
101
119
  ## REPL Commands
102
120
 
103
121
  Once in the REPL, these commands are available:
package/dist/index.js CHANGED
@@ -3,33 +3,17 @@
3
3
  // src/index.ts
4
4
  import { program } from "commander";
5
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 {
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";
6
+ // src/repl.ts
7
+ import { readFile } from "fs/promises";
8
+ import { createInterface } from "readline";
9
+ import pc from "picocolors";
26
10
 
27
11
  // src/interceptor.ts
28
12
  import { mkdir, writeFile } from "fs/promises";
29
13
  import { tmpdir } from "os";
30
14
  import { join } from "path";
31
15
  var BASE64_PATTERN = /^[A-Za-z0-9+/]{1000,}={0,2}$/;
32
- var DEFAULT_TIMEOUT_MS = 6e4;
16
+ var DEFAULT_TIMEOUT_MS = 3e5;
33
17
  var DEFAULT_MAX_TEXT_LENGTH = 5e4;
34
18
  var ResponseInterceptor = class {
35
19
  outDir;
@@ -49,7 +33,10 @@ var ResponseInterceptor = class {
49
33
  */
50
34
  async callTool(target, name, args = {}, timeoutMs) {
51
35
  const timeout = timeoutMs ?? this.defaultTimeoutMs;
52
- const result = await Promise.race([target.callTool(name, args), this._timeout(timeout, name)]);
36
+ const targetCall = target.callTool(name, args);
37
+ targetCall.catch(() => {
38
+ });
39
+ const result = await Promise.race([targetCall, this._timeout(timeout, name)]);
53
40
  const content = result.content;
54
41
  if (Array.isArray(content)) {
55
42
  for (let i = 0; i < content.length; i++) {
@@ -146,6 +133,67 @@ var ResponseInterceptor = class {
146
133
  }
147
134
  };
148
135
 
136
+ // src/parsing.ts
137
+ function parseCommandLine(input) {
138
+ const spaceIdx = input.indexOf(" ");
139
+ if (spaceIdx === -1) {
140
+ return { cmd: input.toLowerCase(), rest: "" };
141
+ }
142
+ return {
143
+ cmd: input.slice(0, spaceIdx).toLowerCase(),
144
+ rest: input.slice(spaceIdx + 1)
145
+ };
146
+ }
147
+ function parseCallArgs(rest) {
148
+ const trimmed = rest.trim();
149
+ if (!trimmed) return { toolName: "", jsonArgs: "" };
150
+ const spaceIdx = trimmed.indexOf(" ");
151
+ if (spaceIdx === -1) {
152
+ return { toolName: trimmed, jsonArgs: "" };
153
+ }
154
+ const toolName = trimmed.slice(0, spaceIdx);
155
+ let remainder = trimmed.slice(spaceIdx + 1).trim();
156
+ let timeoutMs;
157
+ const timeoutMatch = remainder.match(/\s--timeout\s+(\d+)\s*$/);
158
+ if (timeoutMatch) {
159
+ timeoutMs = parseInt(timeoutMatch[1], 10);
160
+ remainder = remainder.slice(0, timeoutMatch.index).trim();
161
+ }
162
+ return { toolName, jsonArgs: remainder, timeoutMs };
163
+ }
164
+ function formatJson(obj, indent = 2) {
165
+ const json = JSON.stringify(obj, null, indent);
166
+ return json.split("\n").map((line) => " ".repeat(indent) + line).join("\n");
167
+ }
168
+ function levenshtein(a, b) {
169
+ const m = a.length;
170
+ const n = b.length;
171
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
172
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
173
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
174
+ for (let i = 1; i <= m; i++) {
175
+ for (let j = 1; j <= n; j++) {
176
+ 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]);
177
+ }
178
+ }
179
+ return dp[m][n];
180
+ }
181
+ function suggestCommand(input, commands, threshold = 0.4) {
182
+ let best = null;
183
+ let bestDist = Infinity;
184
+ for (const cmd of commands) {
185
+ const dist = levenshtein(input, cmd);
186
+ if (dist < bestDist) {
187
+ bestDist = dist;
188
+ best = cmd;
189
+ }
190
+ }
191
+ if (best && bestDist <= Math.ceil(input.length * threshold)) {
192
+ return best;
193
+ }
194
+ return null;
195
+ }
196
+
149
197
  // src/target-manager.ts
150
198
  import { EventEmitter } from "events";
151
199
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -167,6 +215,8 @@ var TargetManager = class _TargetManager extends EventEmitter {
167
215
  // Enhanced status tracking
168
216
  _lastResponseTime = null;
169
217
  _stderrLineCount = 0;
218
+ _stderrLines = [];
219
+ static MAX_STDERR_LINES = 200;
170
220
  // Auto-reconnect state
171
221
  _reconnectAttempts = 0;
172
222
  _stableTimer = null;
@@ -193,11 +243,16 @@ var TargetManager = class _TargetManager extends EventEmitter {
193
243
  this.transport.stderr?.on("data", (chunk) => {
194
244
  const text = chunk.toString().trimEnd();
195
245
  if (text) {
196
- this._stderrLineCount += text.split("\n").length;
246
+ const lines = text.split("\n");
247
+ this._stderrLineCount += lines.length;
248
+ this._stderrLines.push(...lines);
249
+ if (this._stderrLines.length > _TargetManager.MAX_STDERR_LINES) {
250
+ this._stderrLines = this._stderrLines.slice(-_TargetManager.MAX_STDERR_LINES);
251
+ }
197
252
  this.emit("stderr", text);
198
253
  }
199
254
  });
200
- this.client = new Client({ name: "run-mcp", version: "1.2.0" }, { capabilities: {} });
255
+ this.client = new Client({ name: "run-mcp", version: "1.3.1" }, { capabilities: {} });
201
256
  this.client.onclose = () => {
202
257
  this._connected = false;
203
258
  this._clearStableTimer();
@@ -255,10 +310,15 @@ var TargetManager = class _TargetManager extends EventEmitter {
255
310
  }
256
311
  /**
257
312
  * Call a tool on the target MCP server.
313
+ * We apply a massive SDK-level timeout (e.g. 10 hours) because we want to handle
314
+ * timeouts in the interceptor via Promise.race, and we DO NOT want to send
315
+ * protocol-level cancellation requests to the target server if the agent gives up.
316
+ * This allows long-running builds (like mobile app compiling) to finish in the background.
258
317
  */
259
- async callTool(name, args = {}) {
318
+ async callTool(name, args = {}, _timeoutMs) {
260
319
  this._assertConnected();
261
- const result = await this.client.callTool({ name, arguments: args });
320
+ const requestOptions = { timeout: 36e5 * 10 };
321
+ const result = await this.client.callTool({ name, arguments: args }, void 0, requestOptions);
262
322
  this.recordResponse();
263
323
  return result;
264
324
  }
@@ -360,6 +420,14 @@ var TargetManager = class _TargetManager extends EventEmitter {
360
420
  return this.client;
361
421
  }
362
422
  // ─── Status & lifecycle ─────────────────────────────────────────────────────
423
+ /**
424
+ * Returns the last N lines of stderr output from the target server.
425
+ * Useful for debugging crashes or unexpected behavior.
426
+ */
427
+ getStderrLines(count) {
428
+ if (!count || count >= this._stderrLines.length) return [...this._stderrLines];
429
+ return this._stderrLines.slice(-count);
430
+ }
363
431
  /**
364
432
  * Returns current connection status, PID, uptime, and diagnostics.
365
433
  */
@@ -496,217 +564,6 @@ var TargetManager = class _TargetManager extends EventEmitter {
496
564
  }
497
565
  };
498
566
 
499
- // src/proxy.ts
500
- async function startProxy(targetCommand, opts) {
501
- const [command, ...args] = targetCommand;
502
- const target = new TargetManager(command, args);
503
- const interceptor = new ResponseInterceptor({
504
- outDir: opts.outDir,
505
- defaultTimeoutMs: opts.timeoutMs,
506
- maxTextLength: opts.maxTextLength
507
- });
508
- target.on("stderr", (text) => {
509
- process.stderr.write(`[target] ${text}
510
- `);
511
- });
512
- process.stderr.write("[proxy] Connecting to target MCP server...\n");
513
- try {
514
- await target.connect();
515
- } catch (err) {
516
- process.stderr.write(`[proxy] Failed to connect to target: ${err.message}
517
- `);
518
- process.exit(1);
519
- }
520
- const status = target.getStatus();
521
- process.stderr.write(`[proxy] Connected to target (PID: ${status.pid})
522
- `);
523
- const targetCaps = target.getServerCapabilities() ?? {};
524
- const proxyCaps = {};
525
- proxyCaps.tools = targetCaps.tools ?? {};
526
- if (targetCaps.resources) proxyCaps.resources = targetCaps.resources;
527
- if (targetCaps.prompts) proxyCaps.prompts = targetCaps.prompts;
528
- if (targetCaps.logging) proxyCaps.logging = targetCaps.logging;
529
- if (targetCaps.completions) proxyCaps.completions = targetCaps.completions;
530
- process.stderr.write(`[proxy] Mirroring capabilities: ${Object.keys(proxyCaps).join(", ")}
531
- `);
532
- const instructions = target.getInstructions();
533
- if (instructions) {
534
- process.stderr.write(
535
- `[proxy] Target instructions: ${instructions.slice(0, 200)}${instructions.length > 200 ? "..." : ""}
536
- `
537
- );
538
- }
539
- const mcpServer = new McpServer(
540
- {
541
- name: "run-mcp-proxy",
542
- version: "1.2.0"
543
- },
544
- { capabilities: proxyCaps }
545
- );
546
- const server = mcpServer.server;
547
- server.setRequestHandler(ListToolsRequestSchema, async (request) => {
548
- const result = await target.listTools(request.params);
549
- return result;
550
- });
551
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
552
- const { name, arguments: toolArgs } = request.params;
553
- try {
554
- const result = await interceptor.callTool(
555
- target,
556
- name,
557
- toolArgs ?? {}
558
- );
559
- return result;
560
- } catch (err) {
561
- return {
562
- content: [{ type: "text", text: `Error: ${err.message}` }],
563
- isError: true
564
- };
565
- }
566
- });
567
- if (targetCaps.resources) {
568
- server.setRequestHandler(ListResourcesRequestSchema, async (request) => {
569
- return await target.listResources(request.params);
570
- });
571
- server.setRequestHandler(ListResourceTemplatesRequestSchema, async (request) => {
572
- return await target.listResourceTemplates(request.params);
573
- });
574
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
575
- return await target.readResource(request.params);
576
- });
577
- if (targetCaps.resources.subscribe) {
578
- server.setRequestHandler(SubscribeRequestSchema, async (request) => {
579
- return await target.subscribeResource(request.params);
580
- });
581
- server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
582
- return await target.unsubscribeResource(request.params);
583
- });
584
- }
585
- }
586
- if (targetCaps.prompts) {
587
- server.setRequestHandler(ListPromptsRequestSchema, async (request) => {
588
- return await target.listPrompts(request.params);
589
- });
590
- server.setRequestHandler(GetPromptRequestSchema, async (request) => {
591
- return await target.getPrompt(request.params);
592
- });
593
- }
594
- if (targetCaps.logging) {
595
- server.setRequestHandler(SetLevelRequestSchema, async (request) => {
596
- return await target.setLoggingLevel(request.params.level);
597
- });
598
- }
599
- if (targetCaps.completions) {
600
- server.setRequestHandler(CompleteRequestSchema, async (request) => {
601
- return await target.complete(request.params);
602
- });
603
- }
604
- const rawClient = target.getRawClient();
605
- if (rawClient) {
606
- if (targetCaps.tools && targetCaps.tools.listChanged) {
607
- rawClient.setNotificationHandler(ToolListChangedNotificationSchema, () => {
608
- server.notification({ method: "notifications/tools/list_changed" });
609
- });
610
- }
611
- if (targetCaps.resources && targetCaps.resources.listChanged) {
612
- rawClient.setNotificationHandler(ResourceListChangedNotificationSchema, () => {
613
- server.notification({ method: "notifications/resources/list_changed" });
614
- });
615
- }
616
- if (targetCaps.prompts && targetCaps.prompts.listChanged) {
617
- rawClient.setNotificationHandler(PromptListChangedNotificationSchema, () => {
618
- server.notification({ method: "notifications/prompts/list_changed" });
619
- });
620
- }
621
- if (targetCaps.logging) {
622
- rawClient.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
623
- server.notification({
624
- method: "notifications/message",
625
- params: notification.params
626
- });
627
- });
628
- }
629
- }
630
- const transport = new StdioServerTransport();
631
- server.onclose = async () => {
632
- process.stderr.write("[proxy] Parent disconnected, shutting down...\n");
633
- await target.close();
634
- process.exit(0);
635
- };
636
- await mcpServer.connect(transport);
637
- process.stderr.write("[proxy] Proxy server running on stdio.\n");
638
- target.on("disconnected", () => {
639
- process.stderr.write("[proxy] Target server disconnected.\n");
640
- process.exit(1);
641
- });
642
- }
643
-
644
- // src/repl.ts
645
- import { readFile } from "fs/promises";
646
- import { createInterface } from "readline";
647
- import pc from "picocolors";
648
-
649
- // src/parsing.ts
650
- function parseCommandLine(input) {
651
- const spaceIdx = input.indexOf(" ");
652
- if (spaceIdx === -1) {
653
- return { cmd: input.toLowerCase(), rest: "" };
654
- }
655
- return {
656
- cmd: input.slice(0, spaceIdx).toLowerCase(),
657
- rest: input.slice(spaceIdx + 1)
658
- };
659
- }
660
- function parseCallArgs(rest) {
661
- const trimmed = rest.trim();
662
- if (!trimmed) return { toolName: "", jsonArgs: "" };
663
- const spaceIdx = trimmed.indexOf(" ");
664
- if (spaceIdx === -1) {
665
- return { toolName: trimmed, jsonArgs: "" };
666
- }
667
- const toolName = trimmed.slice(0, spaceIdx);
668
- let remainder = trimmed.slice(spaceIdx + 1).trim();
669
- let timeoutMs;
670
- const timeoutMatch = remainder.match(/\s--timeout\s+(\d+)\s*$/);
671
- if (timeoutMatch) {
672
- timeoutMs = parseInt(timeoutMatch[1], 10);
673
- remainder = remainder.slice(0, timeoutMatch.index).trim();
674
- }
675
- return { toolName, jsonArgs: remainder, timeoutMs };
676
- }
677
- function formatJson(obj, indent = 2) {
678
- const json = JSON.stringify(obj, null, indent);
679
- return json.split("\n").map((line) => " ".repeat(indent) + line).join("\n");
680
- }
681
- function levenshtein(a, b) {
682
- const m = a.length;
683
- const n = b.length;
684
- const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
685
- for (let i = 0; i <= m; i++) dp[i][0] = i;
686
- for (let j = 0; j <= n; j++) dp[0][j] = j;
687
- for (let i = 1; i <= m; i++) {
688
- for (let j = 1; j <= n; j++) {
689
- 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]);
690
- }
691
- }
692
- return dp[m][n];
693
- }
694
- function suggestCommand(input, commands, threshold = 0.4) {
695
- let best = null;
696
- let bestDist = Infinity;
697
- for (const cmd of commands) {
698
- const dist = levenshtein(input, cmd);
699
- if (dist < bestDist) {
700
- bestDist = dist;
701
- best = cmd;
702
- }
703
- }
704
- if (best && bestDist <= Math.ceil(input.length * threshold)) {
705
- return best;
706
- }
707
- return null;
708
- }
709
-
710
567
  // src/repl.ts
711
568
  var KNOWN_COMMANDS = [
712
569
  "tools/list",
@@ -973,16 +830,462 @@ async function readScriptLines(filepath) {
973
830
  return content.split("\n");
974
831
  }
975
832
 
833
+ // src/server.ts
834
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
835
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
836
+ import { z } from "zod";
837
+ async function startServer(opts) {
838
+ let target = null;
839
+ const interceptor = new ResponseInterceptor({
840
+ outDir: opts.outDir,
841
+ defaultTimeoutMs: opts.timeoutMs,
842
+ maxTextLength: opts.maxTextLength
843
+ });
844
+ const mcpServer = new McpServer(
845
+ { name: "run-mcp", version: "1.3.1" },
846
+ {
847
+ capabilities: {
848
+ tools: {}
849
+ }
850
+ }
851
+ );
852
+ mcpServer.registerTool(
853
+ "connect_to_mcp",
854
+ {
855
+ title: "Connect to MCP Server",
856
+ 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.",
857
+ inputSchema: {
858
+ command: z.string().describe("Command to run (e.g. 'node', 'python', 'npx')"),
859
+ args: z.array(z.string()).optional().describe("Arguments to pass (e.g. ['src/index.js'] or ['-y', 'some-server'])"),
860
+ env: z.record(z.string()).optional().describe("Extra environment variables for the child process")
861
+ }
862
+ },
863
+ async ({ command, args, env }) => {
864
+ if (target?.connected) {
865
+ return {
866
+ content: [
867
+ {
868
+ type: "text",
869
+ text: "Already connected to a target server. Call disconnect_from_mcp first, then connect again."
870
+ }
871
+ ],
872
+ isError: true
873
+ };
874
+ }
875
+ if (target) {
876
+ await target.close();
877
+ target = null;
878
+ }
879
+ try {
880
+ if (env) {
881
+ for (const [key, value] of Object.entries(env)) {
882
+ process.env[key] = value;
883
+ }
884
+ }
885
+ target = new TargetManager(command, args ?? []);
886
+ await target.connect();
887
+ const status = target.getStatus();
888
+ const caps = target.getServerCapabilities() ?? {};
889
+ const capSummary = [];
890
+ if (caps.tools) capSummary.push("tools");
891
+ if (caps.resources) capSummary.push("resources");
892
+ if (caps.prompts) capSummary.push("prompts");
893
+ if (caps.logging) capSummary.push("logging");
894
+ let toolCount = 0;
895
+ try {
896
+ const tools = await target.listTools();
897
+ toolCount = tools.tools.length;
898
+ } catch {
899
+ }
900
+ const lines = [
901
+ `Connected to MCP server (PID: ${status.pid})`,
902
+ `Command: ${command} ${(args ?? []).join(" ")}`,
903
+ `Capabilities: ${capSummary.join(", ") || "none"}`,
904
+ `Tools available: ${toolCount}`,
905
+ "",
906
+ "Use list_mcp_tools, call_mcp_tool, list_mcp_resources, etc. to interact with it.",
907
+ "Use disconnect_from_mcp when done, or to reconnect after code changes."
908
+ ];
909
+ return { content: [{ type: "text", text: lines.join("\n") }] };
910
+ } catch (err) {
911
+ target = null;
912
+ return {
913
+ content: [
914
+ {
915
+ type: "text",
916
+ text: `Failed to connect: ${err.message}
917
+
918
+ 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.`
919
+ }
920
+ ],
921
+ isError: true
922
+ };
923
+ }
924
+ }
925
+ );
926
+ mcpServer.registerTool(
927
+ "disconnect_from_mcp",
928
+ {
929
+ title: "Disconnect from MCP Server",
930
+ description: "Tear down the current MCP server connection. Call this before reconnecting after code changes."
931
+ },
932
+ async () => {
933
+ if (!target) {
934
+ return {
935
+ content: [{ type: "text", text: "No target server is connected." }],
936
+ isError: true
937
+ };
938
+ }
939
+ const status = target.getStatus();
940
+ await target.close();
941
+ target = null;
942
+ return {
943
+ content: [
944
+ {
945
+ type: "text",
946
+ text: `Disconnected from MCP server (was PID: ${status.pid}, uptime: ${status.uptime.toFixed(1)}s).`
947
+ }
948
+ ]
949
+ };
950
+ }
951
+ );
952
+ mcpServer.registerTool(
953
+ "mcp_server_status",
954
+ {
955
+ title: "MCP Server Status",
956
+ description: "Check the current target server connection status, PID, uptime, and capabilities."
957
+ },
958
+ async () => {
959
+ if (!target) {
960
+ return {
961
+ content: [
962
+ {
963
+ type: "text",
964
+ text: "No target server connected. Use connect_to_mcp to connect to one."
965
+ }
966
+ ]
967
+ };
968
+ }
969
+ const status = target.getStatus();
970
+ const caps = target.getServerCapabilities() ?? {};
971
+ const lines = [
972
+ `Connected: ${status.connected}`,
973
+ `PID: ${status.pid}`,
974
+ `Uptime: ${status.uptime.toFixed(1)}s`,
975
+ `Command: ${status.command} ${status.args.join(" ")}`,
976
+ `Capabilities: ${Object.keys(caps).join(", ") || "none"}`,
977
+ `Stderr lines: ${status.stderrLineCount}`,
978
+ `Last response: ${status.lastResponseTime ? new Date(status.lastResponseTime).toISOString() : "none"}`
979
+ ];
980
+ return { content: [{ type: "text", text: lines.join("\n") }] };
981
+ }
982
+ );
983
+ mcpServer.registerTool(
984
+ "list_mcp_tools",
985
+ {
986
+ title: "List MCP Tools",
987
+ description: "List all tools exposed by the connected MCP server, including descriptions, input schemas, and annotations."
988
+ },
989
+ async () => {
990
+ if (!target?.connected) {
991
+ return {
992
+ content: [
993
+ {
994
+ type: "text",
995
+ text: "No target server connected. Use connect_to_mcp first."
996
+ }
997
+ ],
998
+ isError: true
999
+ };
1000
+ }
1001
+ try {
1002
+ const result = await target.listTools();
1003
+ return {
1004
+ content: [
1005
+ {
1006
+ type: "text",
1007
+ text: JSON.stringify(result.tools, null, 2)
1008
+ }
1009
+ ]
1010
+ };
1011
+ } catch (err) {
1012
+ return {
1013
+ content: [{ type: "text", text: `Error listing tools: ${err.message}` }],
1014
+ isError: true
1015
+ };
1016
+ }
1017
+ }
1018
+ );
1019
+ mcpServer.registerTool(
1020
+ "describe_mcp_tool",
1021
+ {
1022
+ title: "Describe MCP Tool",
1023
+ description: "Get the description and input schema for a specific tool on the connected server.",
1024
+ inputSchema: {
1025
+ name: z.string().describe("Name of the tool to describe")
1026
+ }
1027
+ },
1028
+ async ({ name }) => {
1029
+ if (!target?.connected) {
1030
+ return {
1031
+ content: [
1032
+ { type: "text", text: "No target server connected. Use connect_to_mcp first." }
1033
+ ],
1034
+ isError: true
1035
+ };
1036
+ }
1037
+ try {
1038
+ const result = await target.listTools();
1039
+ const tool = result.tools.find((t) => t.name === name);
1040
+ if (!tool) {
1041
+ const available = result.tools.map((t) => t.name).join(", ");
1042
+ return {
1043
+ content: [
1044
+ { type: "text", text: `Tool "${name}" not found.
1045
+ Available tools: ${available}` }
1046
+ ],
1047
+ isError: true
1048
+ };
1049
+ }
1050
+ return {
1051
+ content: [
1052
+ { type: "text", text: JSON.stringify(tool, null, 2) }
1053
+ ]
1054
+ };
1055
+ } catch (err) {
1056
+ return {
1057
+ content: [{ type: "text", text: `Error describing tool: ${err.message}` }],
1058
+ isError: true
1059
+ };
1060
+ }
1061
+ }
1062
+ );
1063
+ mcpServer.registerTool(
1064
+ "call_mcp_tool",
1065
+ {
1066
+ title: "Call MCP Tool",
1067
+ 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.",
1068
+ inputSchema: {
1069
+ name: z.string().describe("Name of the tool to call"),
1070
+ arguments: z.record(z.unknown()).optional().describe("Arguments to pass to the tool (as a JSON object)"),
1071
+ timeout_ms: z.number().optional().describe("Timeout for this specific call in milliseconds (overrides default)")
1072
+ }
1073
+ },
1074
+ async ({ name, arguments: toolArgs, timeout_ms }) => {
1075
+ if (!target?.connected) {
1076
+ return {
1077
+ content: [
1078
+ {
1079
+ type: "text",
1080
+ text: "No target server connected. Use connect_to_mcp first."
1081
+ }
1082
+ ],
1083
+ isError: true
1084
+ };
1085
+ }
1086
+ try {
1087
+ const result = await interceptor.callTool(
1088
+ target,
1089
+ name,
1090
+ toolArgs ?? {},
1091
+ timeout_ms
1092
+ );
1093
+ return result;
1094
+ } catch (err) {
1095
+ return {
1096
+ content: [{ type: "text", text: `Error: ${err.message}` }],
1097
+ isError: true
1098
+ };
1099
+ }
1100
+ }
1101
+ );
1102
+ mcpServer.registerTool(
1103
+ "list_mcp_resources",
1104
+ {
1105
+ title: "List MCP Resources",
1106
+ description: "List all resources exposed by the connected MCP server."
1107
+ },
1108
+ async () => {
1109
+ if (!target?.connected) {
1110
+ return {
1111
+ content: [
1112
+ {
1113
+ type: "text",
1114
+ text: "No target server connected. Use connect_to_mcp first."
1115
+ }
1116
+ ],
1117
+ isError: true
1118
+ };
1119
+ }
1120
+ try {
1121
+ const result = await target.listResources();
1122
+ return {
1123
+ content: [{ type: "text", text: JSON.stringify(result.resources, null, 2) }]
1124
+ };
1125
+ } catch (err) {
1126
+ return {
1127
+ content: [{ type: "text", text: `Error listing resources: ${err.message}` }],
1128
+ isError: true
1129
+ };
1130
+ }
1131
+ }
1132
+ );
1133
+ mcpServer.registerTool(
1134
+ "read_mcp_resource",
1135
+ {
1136
+ title: "Read MCP Resource",
1137
+ description: "Read a specific resource by URI from the connected MCP server.",
1138
+ inputSchema: {
1139
+ uri: z.string().describe("URI of the resource to read (e.g. 'docs://readme')")
1140
+ }
1141
+ },
1142
+ async ({ uri }) => {
1143
+ if (!target?.connected) {
1144
+ return {
1145
+ content: [
1146
+ {
1147
+ type: "text",
1148
+ text: "No target server connected. Use connect_to_mcp first."
1149
+ }
1150
+ ],
1151
+ isError: true
1152
+ };
1153
+ }
1154
+ try {
1155
+ const result = await target.readResource({ uri });
1156
+ return {
1157
+ content: [{ type: "text", text: JSON.stringify(result.contents, null, 2) }]
1158
+ };
1159
+ } catch (err) {
1160
+ return {
1161
+ content: [{ type: "text", text: `Error reading resource: ${err.message}` }],
1162
+ isError: true
1163
+ };
1164
+ }
1165
+ }
1166
+ );
1167
+ mcpServer.registerTool(
1168
+ "list_mcp_prompts",
1169
+ {
1170
+ title: "List MCP Prompts",
1171
+ description: "List all prompts exposed by the connected MCP server."
1172
+ },
1173
+ async () => {
1174
+ if (!target?.connected) {
1175
+ return {
1176
+ content: [
1177
+ {
1178
+ type: "text",
1179
+ text: "No target server connected. Use connect_to_mcp first."
1180
+ }
1181
+ ],
1182
+ isError: true
1183
+ };
1184
+ }
1185
+ try {
1186
+ const result = await target.listPrompts();
1187
+ return {
1188
+ content: [{ type: "text", text: JSON.stringify(result.prompts, null, 2) }]
1189
+ };
1190
+ } catch (err) {
1191
+ return {
1192
+ content: [{ type: "text", text: `Error listing prompts: ${err.message}` }],
1193
+ isError: true
1194
+ };
1195
+ }
1196
+ }
1197
+ );
1198
+ mcpServer.registerTool(
1199
+ "get_mcp_prompt",
1200
+ {
1201
+ title: "Get MCP Prompt",
1202
+ description: "Get a specific prompt by name from the connected MCP server.",
1203
+ inputSchema: {
1204
+ name: z.string().describe("Name of the prompt"),
1205
+ arguments: z.record(z.string()).optional().describe("Arguments to pass to the prompt")
1206
+ }
1207
+ },
1208
+ async ({ name, arguments: promptArgs }) => {
1209
+ if (!target?.connected) {
1210
+ return {
1211
+ content: [
1212
+ {
1213
+ type: "text",
1214
+ text: "No target server connected. Use connect_to_mcp first."
1215
+ }
1216
+ ],
1217
+ isError: true
1218
+ };
1219
+ }
1220
+ try {
1221
+ const result = await target.getPrompt({
1222
+ name,
1223
+ arguments: promptArgs ?? {}
1224
+ });
1225
+ return {
1226
+ content: [{ type: "text", text: JSON.stringify(result.messages, null, 2) }]
1227
+ };
1228
+ } catch (err) {
1229
+ return {
1230
+ content: [{ type: "text", text: `Error getting prompt: ${err.message}` }],
1231
+ isError: true
1232
+ };
1233
+ }
1234
+ }
1235
+ );
1236
+ mcpServer.registerTool(
1237
+ "get_mcp_server_stderr",
1238
+ {
1239
+ title: "Get MCP Server Stderr",
1240
+ description: "Get recent stderr output from the target MCP server. Useful for debugging crashes, startup failures, or unexpected behavior.",
1241
+ inputSchema: {
1242
+ lines: z.number().optional().describe("Number of recent lines to return (default: all, max 200)")
1243
+ }
1244
+ },
1245
+ async ({ lines }) => {
1246
+ if (!target) {
1247
+ return {
1248
+ content: [
1249
+ {
1250
+ type: "text",
1251
+ text: "No target server (current or previous). Nothing to show."
1252
+ }
1253
+ ]
1254
+ };
1255
+ }
1256
+ const stderrLines = target.getStderrLines(lines);
1257
+ if (stderrLines.length === 0) {
1258
+ return {
1259
+ content: [{ type: "text", text: "No stderr output captured." }]
1260
+ };
1261
+ }
1262
+ return {
1263
+ content: [{ type: "text", text: stderrLines.join("\n") }]
1264
+ };
1265
+ }
1266
+ );
1267
+ const transport = new StdioServerTransport();
1268
+ mcpServer.server.onclose = async () => {
1269
+ if (target) {
1270
+ await target.close();
1271
+ }
1272
+ process.exit(0);
1273
+ };
1274
+ await mcpServer.connect(transport);
1275
+ process.stderr.write("[server] run-mcp test harness running on stdio.\n");
1276
+ process.stderr.write("[server] Waiting for connect_to_mcp call...\n");
1277
+ }
1278
+
976
1279
  // src/index.ts
977
1280
  program.name("run-mcp").enablePositionalOptions().description(
978
- "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"
979
- ).version("1.2.0").addHelpText(
1281
+ "A smart interactive REPL and live test harness for MCP servers.\n\nOperates in two modes:\n repl - Human-friendly CLI for testing MCP servers interactively\n server - MCP server that lets AI agents dynamically test local MCP servers"
1282
+ ).version("1.3.1").addHelpText(
980
1283
  "after",
981
1284
  `
982
1285
  Examples:
983
- $ run-mcp repl node my-server.js # Interactive testing
1286
+ $ run-mcp repl node my-server.js # Interactive testing (human)
984
1287
  $ run-mcp repl node my-server.js -s test.txt # Run a script
985
- $ run-mcp proxy node my-server.js # Proxy for AI agents
1288
+ $ run-mcp server # Test harness (agent)
986
1289
  $ run-mcp repl npx -y some-mcp-server # Test an npx server
987
1290
 
988
1291
  Run 'run-mcp <command> --help' for detailed options.`
@@ -1008,31 +1311,34 @@ REPL Commands (once connected):
1008
1311
  ).action(async (targetCommand, opts) => {
1009
1312
  await startRepl(targetCommand, opts);
1010
1313
  });
1011
- 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(
1314
+ 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: 300000)").option("--max-text <chars>", "Max text response length before truncation (default: 50000)").addHelpText(
1012
1315
  "after",
1013
1316
  `
1014
1317
  Examples:
1015
- $ run-mcp proxy node my-server.js
1016
- $ run-mcp proxy node my-server.js --out-dir ./images
1017
- $ run-mcp proxy node my-server.js --timeout 120000
1018
- $ run-mcp proxy node my-server.js --max-text 100000
1318
+ $ run-mcp server
1319
+ $ run-mcp server --out-dir ./test-output
1320
+ $ run-mcp server --timeout 120000
1019
1321
 
1020
- Use this in your MCP client configuration to wrap any MCP server:
1322
+ Add to your MCP client configuration:
1021
1323
  {
1022
1324
  "mcpServers": {
1023
- "my-server": {
1024
- "command": "run-mcp",
1025
- "args": ["proxy", "node", "my-server.js"]
1325
+ "run-mcp": {
1326
+ "command": "npx",
1327
+ "args": ["-y", "run-mcp", "server"]
1026
1328
  }
1027
1329
  }
1028
- }`
1029
- ).action(
1030
- async (targetCommand, opts) => {
1031
- await startProxy(targetCommand, {
1032
- outDir: opts.outDir,
1033
- timeoutMs: opts.timeout ? Number.parseInt(opts.timeout, 10) : void 0,
1034
- maxTextLength: opts.maxText ? Number.parseInt(opts.maxText, 10) : void 0
1035
- });
1036
1330
  }
1037
- );
1331
+
1332
+ Then use these tools from your agent:
1333
+ connect_to_mcp \u2192 Spawn and connect to a local MCP server
1334
+ list_mcp_tools \u2192 List tools on the connected server
1335
+ call_mcp_tool \u2192 Call a tool (with interception)
1336
+ disconnect_from_mcp \u2192 Tear down and reconnect after changes`
1337
+ ).action(async (opts) => {
1338
+ await startServer({
1339
+ outDir: opts.outDir,
1340
+ timeoutMs: opts.timeout ? Number.parseInt(opts.timeout, 10) : void 0,
1341
+ maxTextLength: opts.maxText ? Number.parseInt(opts.maxText, 10) : void 0
1342
+ });
1343
+ });
1038
1344
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.2",
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": {