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.
- package/README.md +226 -1
- package/dist/index.js +798 -0
- 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.
|
|
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": "
|
|
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
|
-
"
|
|
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
|
}
|