@silver886/mcp-proxy 0.1.1
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 +178 -0
- package/mcp/dist/host.d.ts +2 -0
- package/mcp/dist/host.js +307 -0
- package/mcp/dist/proxy.d.ts +2 -0
- package/mcp/dist/proxy.js +320 -0
- package/mcp/dist/shared/generated.d.ts +2 -0
- package/mcp/dist/shared/generated.js +5 -0
- package/mcp/dist/shared/protocol.d.ts +40 -0
- package/mcp/dist/shared/protocol.js +74 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# MCP Proxy
|
|
2
|
+
|
|
3
|
+
MCP proxy bridge that forwards [Model Context Protocol](https://modelcontextprotocol.io/) requests across network boundaries via [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/).
|
|
4
|
+
|
|
5
|
+
Works with any MCP client (Claude Code, Cursor, Windsurf, Cline, etc.) and any OS.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
MCP servers that need local resources (Chrome browser, filesystem, GPU, etc.) can't run inside containers or remote environments. This proxy bridges the gap:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
MCP Client (container/remote)
|
|
13
|
+
| stdio
|
|
14
|
+
Proxy Server (same machine as client)
|
|
15
|
+
| HTTP via Cloudflare Tunnel
|
|
16
|
+
Host Agent (machine with the resources)
|
|
17
|
+
| stdio
|
|
18
|
+
Real MCP Servers (chrome-devtools, filesystem, etc.)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
### 1. Start the host agent
|
|
24
|
+
|
|
25
|
+
On the machine where your MCP servers run:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx -p @silver886/mcp-proxy host --config config.json --tunnel
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Example `config.json`:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"servers": {
|
|
36
|
+
"chrome-devtools": {
|
|
37
|
+
"command": "npx",
|
|
38
|
+
"args": ["-y", "chrome-devtools-mcp@latest"],
|
|
39
|
+
"shell": true
|
|
40
|
+
},
|
|
41
|
+
"filesystem": {
|
|
42
|
+
"command": "npx",
|
|
43
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
|
|
44
|
+
"shell": true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The host agent prints a tunnel URL and auth token. Keep it running.
|
|
51
|
+
|
|
52
|
+
### 2. Configure your MCP client
|
|
53
|
+
|
|
54
|
+
Add the proxy as a stdio MCP server. The client launches it automatically.
|
|
55
|
+
|
|
56
|
+
**Claude Code** (`claude mcp add` or `.claude.json`):
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"mcpServers": {
|
|
61
|
+
"chrome-devtools": {
|
|
62
|
+
"type": "stdio",
|
|
63
|
+
"command": "npx",
|
|
64
|
+
"args": ["-y", "-p", "@silver886/mcp-proxy", "proxy"]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Cursor / Windsurf / other MCP clients** — same pattern, add as a stdio server with `npx -p @silver886/mcp-proxy proxy` as the command.
|
|
71
|
+
|
|
72
|
+
### 3. Pair
|
|
73
|
+
|
|
74
|
+
When the MCP client spawns the proxy, the proxy prints a setup URL to stderr:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Configure at: https://mcp-proxy.pages.dev/setup.html#code=...&key=...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Open the URL in a browser. Enter the tunnel URL and auth token from step 1, discover servers, and select tools. The proxy picks up the config automatically and starts forwarding MCP requests.
|
|
81
|
+
|
|
82
|
+
## Architecture
|
|
83
|
+
|
|
84
|
+
### Components
|
|
85
|
+
|
|
86
|
+
| Component | Role | Runs on |
|
|
87
|
+
|-----------|------|---------|
|
|
88
|
+
| **Host Agent** (`host`) | HTTP-to-stdio bridge. Spawns MCP servers, manages sessions, serves MCP Streamable HTTP. | Machine with resources |
|
|
89
|
+
| **Proxy Server** (`proxy`) | Stdio MCP server that forwards requests to the host agent via tunnel. | Machine with MCP client |
|
|
90
|
+
| **Config Page** (Cloudflare Pages) | Device-code pairing. Stores encrypted config in KV with 15-min TTL. | Cloudflare edge |
|
|
91
|
+
|
|
92
|
+
### Pairing flow
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
1. MCP client spawns the proxy (stdio)
|
|
96
|
+
2. Proxy generates pairing code + encryption key, polls Pages RPC
|
|
97
|
+
3. User opens setup URL in browser (code + key in URL hash, never sent to server)
|
|
98
|
+
4. User enters tunnel URL + auth token, discovers servers, selects tools
|
|
99
|
+
5. Setup page encrypts config client-side, stores ciphertext in KV via RPC
|
|
100
|
+
6. Proxy polls, decrypts config, discovers servers, starts forwarding
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Protocol
|
|
104
|
+
|
|
105
|
+
- **Client <-> Proxy**: stdio (JSON-RPC, newline-delimited)
|
|
106
|
+
- **Proxy <-> Host Agent**: HTTP via Cloudflare Tunnel (MCP Streamable HTTP)
|
|
107
|
+
- **Host Agent <-> MCP Servers**: stdio (JSON-RPC, newline-delimited)
|
|
108
|
+
- **Session management**: `Mcp-Session-Id` header between proxy and host agent
|
|
109
|
+
|
|
110
|
+
## Configuration
|
|
111
|
+
|
|
112
|
+
### Host agent config
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"servers": {
|
|
117
|
+
"server-name": {
|
|
118
|
+
"command": "node",
|
|
119
|
+
"args": ["path/to/server.js"],
|
|
120
|
+
"env": { "API_KEY": "..." },
|
|
121
|
+
"shell": false
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
"host": "127.0.0.1",
|
|
125
|
+
"port": 6270
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| Field | Default | Description |
|
|
130
|
+
|-------|---------|-------------|
|
|
131
|
+
| `servers` | _(required)_ | Map of server name to spawn config |
|
|
132
|
+
| `servers.*.command` | _(required)_ | Executable to spawn |
|
|
133
|
+
| `servers.*.args` | `[]` | Command arguments |
|
|
134
|
+
| `servers.*.env` | `{}` | Extra environment variables |
|
|
135
|
+
| `servers.*.shell` | `false` | Use shell for PATH resolution (set `true` for `npx`, etc.) |
|
|
136
|
+
| `host` | `127.0.0.1` | Listen address |
|
|
137
|
+
| `port` | `6270` | Listen port |
|
|
138
|
+
|
|
139
|
+
### CLI
|
|
140
|
+
|
|
141
|
+
**Host agent:**
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
host [options]
|
|
145
|
+
|
|
146
|
+
--config <path> Config file (default: config.json)
|
|
147
|
+
--tunnel Start a Cloudflare quick tunnel
|
|
148
|
+
--timeout <ms> MCP request timeout (default: 120000)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Proxy server:**
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
proxy [options]
|
|
155
|
+
|
|
156
|
+
--pages-url <url> Config page URL (default: https://mcp-proxy.pages.dev)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Also reads `MCP_PROXY_PAGES_URL` environment variable.
|
|
160
|
+
|
|
161
|
+
## Error codes
|
|
162
|
+
|
|
163
|
+
| Code | Name | Meaning |
|
|
164
|
+
|------|------|---------|
|
|
165
|
+
| `-32603` | `INTERNAL` | Unhandled server error |
|
|
166
|
+
| `-32001` | `PROXY_NOT_CONFIGURED` | Proxy hasn't been paired yet |
|
|
167
|
+
| `-32002` | `HOST_UNREACHABLE` | Can't reach host agent via tunnel |
|
|
168
|
+
| `-32003` | `PROCESS_EXITED` | MCP server child process died |
|
|
169
|
+
| `-32004` | `PROCESS_NOT_RUNNING` | Child process isn't running |
|
|
170
|
+
| `-32005` | `REQUEST_TIMEOUT` | MCP server didn't respond in time |
|
|
171
|
+
|
|
172
|
+
## Development
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
pnpm install
|
|
176
|
+
pnpm run build
|
|
177
|
+
pnpm publish --access public --no-git-checks
|
|
178
|
+
```
|
package/mcp/dist/host.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const cloudflared_1 = require("cloudflared");
|
|
8
|
+
const protocol_js_1 = require("./shared/protocol.js");
|
|
9
|
+
// A session manages one MCP server child process + request/response matching
|
|
10
|
+
class McpSession {
|
|
11
|
+
name;
|
|
12
|
+
timeout;
|
|
13
|
+
process;
|
|
14
|
+
stdoutBuffer = new protocol_js_1.LineBuffer();
|
|
15
|
+
pending = new Map();
|
|
16
|
+
notifications = [];
|
|
17
|
+
destroyed = false;
|
|
18
|
+
constructor(name, config, timeout) {
|
|
19
|
+
this.name = name;
|
|
20
|
+
this.timeout = timeout;
|
|
21
|
+
console.log(`[${name}] Spawning: ${config.command} ${config.args.join(" ")}`);
|
|
22
|
+
this.process = (0, node_child_process_1.spawn)(config.command, config.args, {
|
|
23
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
24
|
+
env: { ...process.env, ...config.env },
|
|
25
|
+
shell: config.shell ?? false,
|
|
26
|
+
});
|
|
27
|
+
this.process.stdout.on("data", (chunk) => {
|
|
28
|
+
const lines = this.stdoutBuffer.push(chunk.toString("utf-8"));
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
this.handleLine(line);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
this.process.stderr.on("data", (chunk) => {
|
|
34
|
+
console.error(`[${name}] stderr: ${chunk.toString("utf-8").trimEnd()}`);
|
|
35
|
+
});
|
|
36
|
+
this.process.on("exit", (code) => {
|
|
37
|
+
console.log(`[${name}] Process exited (code=${code})`);
|
|
38
|
+
this.destroyed = true;
|
|
39
|
+
// Reject all pending
|
|
40
|
+
for (const [, p] of this.pending) {
|
|
41
|
+
clearTimeout(p.timer);
|
|
42
|
+
p.resolve((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PROCESS_EXITED, `code=${code}`));
|
|
43
|
+
}
|
|
44
|
+
this.pending.clear();
|
|
45
|
+
});
|
|
46
|
+
this.process.on("error", (err) => {
|
|
47
|
+
console.error(`[${name}] Process error: ${err.message}`);
|
|
48
|
+
this.destroyed = true;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
handleLine(line) {
|
|
52
|
+
// Try to extract the id to match with a pending request
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(line);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return; // Not valid JSON, skip
|
|
59
|
+
}
|
|
60
|
+
// If it has an id and matches a pending request, resolve it
|
|
61
|
+
if (parsed.id !== undefined && this.pending.has(parsed.id)) {
|
|
62
|
+
const p = this.pending.get(parsed.id);
|
|
63
|
+
clearTimeout(p.timer);
|
|
64
|
+
this.pending.delete(parsed.id);
|
|
65
|
+
p.resolve(line);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Otherwise it's a notification — queue it
|
|
69
|
+
this.notifications.push(line);
|
|
70
|
+
}
|
|
71
|
+
sendRequest(jsonRpcLine) {
|
|
72
|
+
if (this.destroyed || !this.process.stdin?.writable) {
|
|
73
|
+
return Promise.resolve((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PROCESS_NOT_RUNNING));
|
|
74
|
+
}
|
|
75
|
+
// Extract id for matching
|
|
76
|
+
let id;
|
|
77
|
+
try {
|
|
78
|
+
id = JSON.parse(jsonRpcLine).id;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// If we can't parse, just send it and hope for the best
|
|
82
|
+
}
|
|
83
|
+
this.process.stdin.write(jsonRpcLine + "\n");
|
|
84
|
+
if (id === undefined) {
|
|
85
|
+
// It's a notification from client — no response expected
|
|
86
|
+
return Promise.resolve("");
|
|
87
|
+
}
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
this.pending.delete(id);
|
|
91
|
+
resolve((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.REQUEST_TIMEOUT, undefined, id));
|
|
92
|
+
}, this.timeout);
|
|
93
|
+
this.pending.set(id, { resolve, timer });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
drainNotifications() {
|
|
97
|
+
const n = this.notifications;
|
|
98
|
+
this.notifications = [];
|
|
99
|
+
return n;
|
|
100
|
+
}
|
|
101
|
+
get serverName() {
|
|
102
|
+
return this.name;
|
|
103
|
+
}
|
|
104
|
+
get isAlive() {
|
|
105
|
+
return !this.destroyed;
|
|
106
|
+
}
|
|
107
|
+
destroy() {
|
|
108
|
+
if (this.destroyed)
|
|
109
|
+
return;
|
|
110
|
+
this.destroyed = true;
|
|
111
|
+
if (!this.process.killed)
|
|
112
|
+
this.process.kill();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function sendSessionMismatchError(res, session, serverName) {
|
|
116
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
117
|
+
res.end(JSON.stringify({ error: `Session belongs to server '${session.serverName}', not '${serverName}'` }));
|
|
118
|
+
}
|
|
119
|
+
// Main server
|
|
120
|
+
class HostAgent {
|
|
121
|
+
config;
|
|
122
|
+
sessions = new Map();
|
|
123
|
+
timeout;
|
|
124
|
+
authToken;
|
|
125
|
+
constructor(configPath, timeout) {
|
|
126
|
+
const raw = (0, node_fs_1.readFileSync)(configPath, "utf-8");
|
|
127
|
+
this.config = JSON.parse(raw);
|
|
128
|
+
this.timeout = timeout;
|
|
129
|
+
this.authToken = (0, node_crypto_1.randomBytes)(32).toString("base64url"); // 256-bit token
|
|
130
|
+
}
|
|
131
|
+
get port() {
|
|
132
|
+
return this.config.port ?? protocol_js_1.DEFAULT_PORT;
|
|
133
|
+
}
|
|
134
|
+
start() {
|
|
135
|
+
const host = this.config.host ?? protocol_js_1.DEFAULT_HOST;
|
|
136
|
+
const server = (0, protocol_js_1.createServer)((req, res) => this.handleRequest(req, res));
|
|
137
|
+
server.listen(this.port, host, () => {
|
|
138
|
+
console.log(`MCP Host Agent listening on http://${host}:${this.port}`);
|
|
139
|
+
console.log(`Available servers: ${Object.keys(this.config.servers).join(", ")}`);
|
|
140
|
+
console.error(`Auth token: ${this.authToken}`);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async handleRequest(req, res) {
|
|
144
|
+
// Auth: validate Bearer token (constant-time comparison)
|
|
145
|
+
const auth = req.headers.authorization ?? "";
|
|
146
|
+
const expected = `Bearer ${this.authToken}`;
|
|
147
|
+
const authBuf = Buffer.from(auth);
|
|
148
|
+
const expectedBuf = Buffer.from(expected);
|
|
149
|
+
const authorized = authBuf.length === expectedBuf.length && (0, node_crypto_1.timingSafeEqual)(authBuf, expectedBuf);
|
|
150
|
+
if (!authorized) {
|
|
151
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
152
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// GET / — list available servers
|
|
156
|
+
if (req.method === "GET" && req.url === "/") {
|
|
157
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
158
|
+
res.end(JSON.stringify({
|
|
159
|
+
service: "mcp-proxy-host",
|
|
160
|
+
servers: Object.keys(this.config.servers),
|
|
161
|
+
}));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Route: /servers/:name
|
|
165
|
+
const match = req.url?.match(/^\/servers\/([^/?]+)/);
|
|
166
|
+
if (!match) {
|
|
167
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
168
|
+
res.end(JSON.stringify({ error: "Not found. Use /servers/<name>" }));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const serverName = match[1];
|
|
172
|
+
const serverConfig = this.config.servers[serverName];
|
|
173
|
+
if (!serverConfig) {
|
|
174
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
175
|
+
res.end(JSON.stringify({
|
|
176
|
+
error: `Unknown server: ${serverName}`,
|
|
177
|
+
available: Object.keys(this.config.servers),
|
|
178
|
+
}));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// POST /servers/:name — MCP request
|
|
182
|
+
if (req.method === "POST") {
|
|
183
|
+
await this.handleMcpPost(req, res, serverName, serverConfig);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// GET /servers/:name — SSE for server notifications
|
|
187
|
+
if (req.method === "GET") {
|
|
188
|
+
this.handleSse(req, res, serverName);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// DELETE /servers/:name — close session
|
|
192
|
+
if (req.method === "DELETE") {
|
|
193
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
194
|
+
if (sessionId && this.sessions.has(sessionId)) {
|
|
195
|
+
const session = this.sessions.get(sessionId);
|
|
196
|
+
if (session.serverName !== serverName) {
|
|
197
|
+
sendSessionMismatchError(res, session, serverName);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
session.destroy();
|
|
201
|
+
this.sessions.delete(sessionId);
|
|
202
|
+
}
|
|
203
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
204
|
+
res.end(JSON.stringify({ ok: true }));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
res.writeHead(405);
|
|
208
|
+
res.end();
|
|
209
|
+
}
|
|
210
|
+
async handleMcpPost(req, res, serverName, serverConfig) {
|
|
211
|
+
const body = await (0, protocol_js_1.readBody)(req);
|
|
212
|
+
let sessionId = req.headers["mcp-session-id"];
|
|
213
|
+
// Get or create session
|
|
214
|
+
let session;
|
|
215
|
+
if (sessionId && this.sessions.has(sessionId)) {
|
|
216
|
+
session = this.sessions.get(sessionId);
|
|
217
|
+
if (session.serverName !== serverName) {
|
|
218
|
+
sendSessionMismatchError(res, session, serverName);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (!session.isAlive) {
|
|
222
|
+
// Session dead — clean up and create new
|
|
223
|
+
this.sessions.delete(sessionId);
|
|
224
|
+
sessionId = undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!sessionId || !this.sessions.has(sessionId)) {
|
|
228
|
+
sessionId = (0, node_crypto_1.randomBytes)(16).toString("hex");
|
|
229
|
+
session = new McpSession(serverName, serverConfig, this.timeout);
|
|
230
|
+
this.sessions.set(sessionId, session);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
session = this.sessions.get(sessionId);
|
|
234
|
+
}
|
|
235
|
+
// Forward request
|
|
236
|
+
const response = await session.sendRequest(body);
|
|
237
|
+
if (!response) {
|
|
238
|
+
// Client notification — no response body
|
|
239
|
+
res.writeHead(202, { "Mcp-Session-Id": sessionId });
|
|
240
|
+
res.end();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
res.writeHead(200, {
|
|
244
|
+
"Content-Type": "application/json",
|
|
245
|
+
"Mcp-Session-Id": sessionId,
|
|
246
|
+
});
|
|
247
|
+
res.end(response);
|
|
248
|
+
}
|
|
249
|
+
handleSse(req, res, serverName) {
|
|
250
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
251
|
+
const session = sessionId ? this.sessions.get(sessionId) : undefined;
|
|
252
|
+
if (session && session.serverName !== serverName) {
|
|
253
|
+
sendSessionMismatchError(res, session, serverName);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
res.writeHead(200, {
|
|
257
|
+
"Content-Type": "text/event-stream",
|
|
258
|
+
"Cache-Control": "no-cache",
|
|
259
|
+
Connection: "keep-alive",
|
|
260
|
+
...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
|
|
261
|
+
});
|
|
262
|
+
res.write(": connected\n\n");
|
|
263
|
+
if (!session) {
|
|
264
|
+
req.on("close", () => { });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Poll for notifications and send them
|
|
268
|
+
const interval = setInterval(() => {
|
|
269
|
+
if (!session.isAlive) {
|
|
270
|
+
clearInterval(interval);
|
|
271
|
+
res.end();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const notifications = session.drainNotifications();
|
|
275
|
+
for (const n of notifications) {
|
|
276
|
+
res.write(`data: ${n}\n\n`);
|
|
277
|
+
}
|
|
278
|
+
}, 100);
|
|
279
|
+
req.on("close", () => clearInterval(interval));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function startTunnel(port) {
|
|
283
|
+
const tunnel = cloudflared_1.Tunnel.quick(`http://localhost:${port}`);
|
|
284
|
+
tunnel.once("url", (url) => {
|
|
285
|
+
console.log(`\n Tunnel URL: ${url}`);
|
|
286
|
+
console.log(`\n Enter this URL in the setup page when configuring the proxy.\n`);
|
|
287
|
+
});
|
|
288
|
+
tunnel.on("error", (err) => {
|
|
289
|
+
console.error("Tunnel error:", err.message);
|
|
290
|
+
});
|
|
291
|
+
process.on("SIGINT", () => {
|
|
292
|
+
tunnel.stop();
|
|
293
|
+
process.exit(0);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
function main() {
|
|
297
|
+
const configPath = (0, protocol_js_1.getArg)("--config") ?? "config.json";
|
|
298
|
+
const timeout = parseInt((0, protocol_js_1.getArg)("--timeout") ?? "120000", 10); // 2min default for long tool calls
|
|
299
|
+
const useTunnel = process.argv.includes("--tunnel");
|
|
300
|
+
const agent = new HostAgent(configPath, timeout);
|
|
301
|
+
agent.start();
|
|
302
|
+
if (useTunnel) {
|
|
303
|
+
console.log("Starting Cloudflare tunnel...");
|
|
304
|
+
startTunnel(agent.port);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
main();
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const protocol_js_1 = require("./shared/protocol.js");
|
|
6
|
+
const POLL_INTERVAL = 2000; // ms
|
|
7
|
+
const TOOL_SEPARATOR = "__";
|
|
8
|
+
const subtle = node_crypto_1.webcrypto.subtle;
|
|
9
|
+
// --- Crypto helpers (AES-256-GCM + SHA-256 + HMAC) ---
|
|
10
|
+
async function importAesKey(keyB64) {
|
|
11
|
+
const raw = Buffer.from(keyB64, "base64url");
|
|
12
|
+
return subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
13
|
+
}
|
|
14
|
+
async function importHmacKey(keyB64) {
|
|
15
|
+
const raw = Buffer.from(keyB64, "base64url");
|
|
16
|
+
return subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
17
|
+
}
|
|
18
|
+
async function deriveCodeId(code) {
|
|
19
|
+
const hash = await subtle.digest("SHA-256", new TextEncoder().encode(code));
|
|
20
|
+
return Buffer.from(hash).toString("base64url");
|
|
21
|
+
}
|
|
22
|
+
async function deriveAuthHash(keyB64, code) {
|
|
23
|
+
const hmacKey = await importHmacKey(keyB64);
|
|
24
|
+
const sig = await subtle.sign("HMAC", hmacKey, new TextEncoder().encode(code));
|
|
25
|
+
return Buffer.from(sig).toString("base64url");
|
|
26
|
+
}
|
|
27
|
+
async function encrypt(key, plaintext) {
|
|
28
|
+
const iv = (0, node_crypto_1.randomBytes)(12);
|
|
29
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
30
|
+
const ciphertext = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv }, key, encoded));
|
|
31
|
+
const combined = new Uint8Array(iv.length + ciphertext.length);
|
|
32
|
+
combined.set(iv);
|
|
33
|
+
combined.set(ciphertext, iv.length);
|
|
34
|
+
return Buffer.from(combined).toString("base64url");
|
|
35
|
+
}
|
|
36
|
+
async function decrypt(key, data) {
|
|
37
|
+
const combined = Buffer.from(data, "base64url");
|
|
38
|
+
const iv = combined.subarray(0, 12);
|
|
39
|
+
const ciphertext = combined.subarray(12);
|
|
40
|
+
const plaintext = await subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
|
|
41
|
+
return new TextDecoder().decode(plaintext);
|
|
42
|
+
}
|
|
43
|
+
// --- RPC client ---
|
|
44
|
+
async function rpc(pagesUrl, codeId, authHash, action, payload) {
|
|
45
|
+
const resp = await fetch(`${pagesUrl}/api/rpc`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "Content-Type": "application/json" },
|
|
48
|
+
body: JSON.stringify({ codeId, authHash, action, payload }),
|
|
49
|
+
});
|
|
50
|
+
return (await resp.json());
|
|
51
|
+
}
|
|
52
|
+
// --- Proxy ---
|
|
53
|
+
class ProxyServer {
|
|
54
|
+
config = null;
|
|
55
|
+
pagesUrl;
|
|
56
|
+
code;
|
|
57
|
+
encKeyB64;
|
|
58
|
+
aesKey = null;
|
|
59
|
+
codeId = null;
|
|
60
|
+
authHash = null;
|
|
61
|
+
pollTimer = null;
|
|
62
|
+
servers = new Map();
|
|
63
|
+
toolRoute = new Map();
|
|
64
|
+
initialized = false;
|
|
65
|
+
constructor(pagesUrl) {
|
|
66
|
+
this.pagesUrl = pagesUrl.replace(/\/+$/, "");
|
|
67
|
+
this.code = (0, node_crypto_1.randomBytes)(64).toString("base64url");
|
|
68
|
+
this.encKeyB64 = (0, node_crypto_1.randomBytes)(32).toString("base64url");
|
|
69
|
+
}
|
|
70
|
+
get setupUrl() {
|
|
71
|
+
return `${this.pagesUrl}/setup.html#code=${this.code}&key=${this.encKeyB64}`;
|
|
72
|
+
}
|
|
73
|
+
get hostHeaders() {
|
|
74
|
+
return {
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
Accept: "application/json, text/event-stream",
|
|
77
|
+
Authorization: `Bearer ${this.config?.authToken ?? ""}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async ensureDerivedKeys() {
|
|
81
|
+
if (!this.aesKey)
|
|
82
|
+
this.aesKey = await importAesKey(this.encKeyB64);
|
|
83
|
+
if (!this.codeId)
|
|
84
|
+
this.codeId = await deriveCodeId(this.code);
|
|
85
|
+
if (!this.authHash)
|
|
86
|
+
this.authHash = await deriveAuthHash(this.encKeyB64, this.code);
|
|
87
|
+
return { aesKey: this.aesKey, codeId: this.codeId, authHash: this.authHash };
|
|
88
|
+
}
|
|
89
|
+
start() {
|
|
90
|
+
this.startPairing();
|
|
91
|
+
const stdinBuffer = new protocol_js_1.LineBuffer();
|
|
92
|
+
process.stdin.setEncoding("utf-8");
|
|
93
|
+
process.stdin.on("data", (chunk) => {
|
|
94
|
+
const lines = stdinBuffer.push(chunk);
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
this.handleLine(line).catch((err) => {
|
|
97
|
+
process.stderr.write(`Proxy error: ${err.message}\n`);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
process.stdin.on("end", () => {
|
|
102
|
+
process.exit(0);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async handleLine(line) {
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(line);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const id = parsed.id ?? null;
|
|
114
|
+
if (id === null)
|
|
115
|
+
return;
|
|
116
|
+
if (!this.config) {
|
|
117
|
+
process.stdout.write((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, `Visit ${this.setupUrl}`, id) + "\n");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
switch (parsed.method) {
|
|
121
|
+
case "initialize":
|
|
122
|
+
this.sendResult(id, {
|
|
123
|
+
protocolVersion: "2024-11-05",
|
|
124
|
+
capabilities: { tools: {} },
|
|
125
|
+
serverInfo: { name: protocol_js_1.PACKAGE_NAME, version: protocol_js_1.PACKAGE_VERSION },
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
case "tools/list":
|
|
129
|
+
if (!this.initialized)
|
|
130
|
+
await this.discoverServers();
|
|
131
|
+
this.sendResult(id, { tools: this.getFilteredTools() });
|
|
132
|
+
return;
|
|
133
|
+
case "tools/call":
|
|
134
|
+
await this.handleToolCall(id, parsed.params);
|
|
135
|
+
return;
|
|
136
|
+
default:
|
|
137
|
+
process.stdout.write((0, protocol_js_1.jsonRpcError)(-32601, `Method not found: ${parsed.method}`, id) + "\n");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async handleToolCall(id, params) {
|
|
141
|
+
const prefixedName = params.name;
|
|
142
|
+
const serverName = this.toolRoute.get(prefixedName);
|
|
143
|
+
if (!serverName) {
|
|
144
|
+
process.stdout.write((0, protocol_js_1.jsonRpcError)(-32602, `Unknown tool: ${prefixedName}`, id) + "\n");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const originalName = prefixedName.slice(serverName.length + TOOL_SEPARATOR.length);
|
|
148
|
+
const server = this.servers.get(serverName);
|
|
149
|
+
const targetUrl = `${this.config.tunnelUrl}/servers/${serverName}`;
|
|
150
|
+
const headers = { ...this.hostHeaders };
|
|
151
|
+
if (server.sessionId)
|
|
152
|
+
headers["Mcp-Session-Id"] = server.sessionId;
|
|
153
|
+
try {
|
|
154
|
+
const body = JSON.stringify({
|
|
155
|
+
jsonrpc: "2.0",
|
|
156
|
+
id,
|
|
157
|
+
method: "tools/call",
|
|
158
|
+
params: { name: originalName, arguments: params.arguments },
|
|
159
|
+
});
|
|
160
|
+
const upstream = await fetch(targetUrl, { method: "POST", headers, body });
|
|
161
|
+
server.sessionId = upstream.headers.get("mcp-session-id") ?? server.sessionId;
|
|
162
|
+
const responseBody = await upstream.text();
|
|
163
|
+
if (responseBody)
|
|
164
|
+
process.stdout.write(responseBody + "\n");
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
process.stdout.write((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.HOST_UNREACHABLE, err.message, id) + "\n");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
getFilteredTools() {
|
|
171
|
+
const selectedSet = this.config?.selectedTools?.length
|
|
172
|
+
? new Set(this.config.selectedTools)
|
|
173
|
+
: null;
|
|
174
|
+
const tools = [];
|
|
175
|
+
for (const [serverName, state] of this.servers) {
|
|
176
|
+
for (const tool of state.tools) {
|
|
177
|
+
const prefixed = `${serverName}${TOOL_SEPARATOR}${tool.name}`;
|
|
178
|
+
if (selectedSet && !selectedSet.has(prefixed))
|
|
179
|
+
continue;
|
|
180
|
+
tools.push({
|
|
181
|
+
...tool,
|
|
182
|
+
name: prefixed,
|
|
183
|
+
description: `[${serverName}] ${tool.description ?? ""}`.trim(),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return tools;
|
|
188
|
+
}
|
|
189
|
+
async discoverServers() {
|
|
190
|
+
if (!this.config)
|
|
191
|
+
return;
|
|
192
|
+
try {
|
|
193
|
+
const listResp = await fetch(`${this.config.tunnelUrl}/`, { headers: this.hostHeaders });
|
|
194
|
+
const listData = (await listResp.json());
|
|
195
|
+
const serverNames = listData.servers ?? [];
|
|
196
|
+
// Skip servers whose names contain the tool separator to prevent routing confusion
|
|
197
|
+
const safeNames = serverNames.filter((name) => {
|
|
198
|
+
if (name.includes(TOOL_SEPARATOR)) {
|
|
199
|
+
process.stderr.write(` [${name}] skipped: name contains '${TOOL_SEPARATOR}'\n`);
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
});
|
|
204
|
+
process.stderr.write(` Discovered servers: ${safeNames.join(", ")}\n`);
|
|
205
|
+
for (const name of safeNames) {
|
|
206
|
+
await this.initServer(name);
|
|
207
|
+
}
|
|
208
|
+
this.toolRoute.clear();
|
|
209
|
+
for (const [serverName, state] of this.servers) {
|
|
210
|
+
for (const tool of state.tools) {
|
|
211
|
+
this.toolRoute.set(`${serverName}${TOOL_SEPARATOR}${tool.name}`, serverName);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
this.initialized = true;
|
|
215
|
+
process.stderr.write(` Total tools: ${this.toolRoute.size}\n\n`);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
process.stderr.write(` Discovery failed: ${err.message}\n`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async initServer(name) {
|
|
222
|
+
const targetUrl = `${this.config.tunnelUrl}/servers/${name}`;
|
|
223
|
+
const headers = { ...this.hostHeaders };
|
|
224
|
+
try {
|
|
225
|
+
const initResp = await fetch(targetUrl, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers,
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
jsonrpc: "2.0",
|
|
230
|
+
id: `init-${name}`,
|
|
231
|
+
method: "initialize",
|
|
232
|
+
params: {
|
|
233
|
+
protocolVersion: "2024-11-05",
|
|
234
|
+
capabilities: {},
|
|
235
|
+
clientInfo: { name: protocol_js_1.PACKAGE_NAME, version: protocol_js_1.PACKAGE_VERSION },
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
});
|
|
239
|
+
const sessionId = initResp.headers.get("mcp-session-id") ?? undefined;
|
|
240
|
+
if (sessionId)
|
|
241
|
+
headers["Mcp-Session-Id"] = sessionId;
|
|
242
|
+
await fetch(targetUrl, {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers,
|
|
245
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }),
|
|
246
|
+
});
|
|
247
|
+
const toolsResp = await fetch(targetUrl, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers,
|
|
250
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: `tools-${name}`, method: "tools/list", params: {} }),
|
|
251
|
+
});
|
|
252
|
+
const toolsData = (await toolsResp.json());
|
|
253
|
+
const tools = toolsData.result?.tools ?? [];
|
|
254
|
+
this.servers.set(name, { sessionId, tools });
|
|
255
|
+
process.stderr.write(` [${name}] ${tools.length} tools\n`);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
process.stderr.write(` [${name}] init failed: ${err.message}\n`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
sendResult(id, result) {
|
|
262
|
+
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
|
|
263
|
+
}
|
|
264
|
+
async startPairing() {
|
|
265
|
+
if (this.pollTimer)
|
|
266
|
+
clearInterval(this.pollTimer);
|
|
267
|
+
const previousConfig = this.config;
|
|
268
|
+
this.code = (0, node_crypto_1.randomBytes)(64).toString("base64url");
|
|
269
|
+
this.encKeyB64 = (0, node_crypto_1.randomBytes)(32).toString("base64url");
|
|
270
|
+
this.aesKey = null;
|
|
271
|
+
this.codeId = null;
|
|
272
|
+
this.authHash = null;
|
|
273
|
+
this.config = null;
|
|
274
|
+
this.initialized = false;
|
|
275
|
+
this.servers.clear();
|
|
276
|
+
this.toolRoute.clear();
|
|
277
|
+
// Seed with encrypted previous config (unsealed) — skip on first pairing
|
|
278
|
+
if (previousConfig) {
|
|
279
|
+
try {
|
|
280
|
+
const { aesKey, codeId, authHash } = await this.ensureDerivedKeys();
|
|
281
|
+
const payload = await encrypt(aesKey, JSON.stringify({ ...previousConfig, sealed: false }));
|
|
282
|
+
await rpc(this.pagesUrl, codeId, authHash, "write", payload);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Non-critical
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
process.stderr.write(`\n Configure at: ${this.setupUrl}\n\n`);
|
|
289
|
+
process.stderr.write(` Waiting for configuration...\n`);
|
|
290
|
+
this.pollTimer = setInterval(() => this.pollConfig(), POLL_INTERVAL);
|
|
291
|
+
}
|
|
292
|
+
async pollConfig() {
|
|
293
|
+
try {
|
|
294
|
+
const { aesKey, codeId, authHash } = await this.ensureDerivedKeys();
|
|
295
|
+
const result = await rpc(this.pagesUrl, codeId, authHash, "read");
|
|
296
|
+
if (result.payload) {
|
|
297
|
+
const plaintext = await decrypt(aesKey, result.payload);
|
|
298
|
+
const data = JSON.parse(plaintext);
|
|
299
|
+
if (data.tunnelUrl && data.authToken && data.serverName && data.sealed) {
|
|
300
|
+
data.tunnelUrl = data.tunnelUrl.replace(/\/+$/, "");
|
|
301
|
+
this.config = data;
|
|
302
|
+
if (this.pollTimer)
|
|
303
|
+
clearInterval(this.pollTimer);
|
|
304
|
+
this.pollTimer = null;
|
|
305
|
+
process.stderr.write(` Paired! tunnel=${data.tunnelUrl}\n`);
|
|
306
|
+
await this.discoverServers();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// Silently retry
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function main() {
|
|
316
|
+
const pagesUrl = (0, protocol_js_1.getArg)("--pages-url") ?? process.env.MCP_PROXY_PAGES_URL ?? protocol_js_1.DEFAULT_PAGES_URL;
|
|
317
|
+
const proxy = new ProxyServer(pagesUrl);
|
|
318
|
+
proxy.start();
|
|
319
|
+
}
|
|
320
|
+
main();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export { PACKAGE_NAME, PACKAGE_VERSION } from "./generated.js";
|
|
3
|
+
export declare const DEFAULT_HOST = "127.0.0.1";
|
|
4
|
+
export declare const DEFAULT_PORT = 6270;
|
|
5
|
+
export declare const DEFAULT_PAGES_URL = "https://mcp-proxy.pages.dev";
|
|
6
|
+
export declare const ErrorCode: {
|
|
7
|
+
readonly INTERNAL: -32603;
|
|
8
|
+
readonly PROXY_NOT_CONFIGURED: -32001;
|
|
9
|
+
readonly HOST_UNREACHABLE: -32002;
|
|
10
|
+
readonly PROCESS_EXITED: -32003;
|
|
11
|
+
readonly PROCESS_NOT_RUNNING: -32004;
|
|
12
|
+
readonly REQUEST_TIMEOUT: -32005;
|
|
13
|
+
};
|
|
14
|
+
export declare const ErrorMessage: {
|
|
15
|
+
readonly [-32603]: "Internal error";
|
|
16
|
+
readonly [-32001]: "Proxy not configured";
|
|
17
|
+
readonly [-32002]: "Host agent unreachable";
|
|
18
|
+
readonly [-32003]: "Server process exited";
|
|
19
|
+
readonly [-32004]: "Server process not running";
|
|
20
|
+
readonly [-32005]: "Request timed out";
|
|
21
|
+
};
|
|
22
|
+
export declare function jsonRpcError(code: number, detail?: string, id?: string | number | null): string;
|
|
23
|
+
export declare function readBody(req: IncomingMessage): Promise<string>;
|
|
24
|
+
export declare function getArg(name: string): string | undefined;
|
|
25
|
+
export declare function createServer(handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>): import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
26
|
+
export declare class LineBuffer {
|
|
27
|
+
private buffer;
|
|
28
|
+
push(chunk: string): string[];
|
|
29
|
+
}
|
|
30
|
+
export interface ServerConfig {
|
|
31
|
+
command: string;
|
|
32
|
+
args: string[];
|
|
33
|
+
env?: Record<string, string>;
|
|
34
|
+
shell?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface HostAgentConfig {
|
|
37
|
+
servers: Record<string, ServerConfig>;
|
|
38
|
+
host?: string;
|
|
39
|
+
port?: number;
|
|
40
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LineBuffer = exports.ErrorMessage = exports.ErrorCode = exports.DEFAULT_PAGES_URL = exports.DEFAULT_PORT = exports.DEFAULT_HOST = exports.PACKAGE_VERSION = exports.PACKAGE_NAME = void 0;
|
|
4
|
+
exports.jsonRpcError = jsonRpcError;
|
|
5
|
+
exports.readBody = readBody;
|
|
6
|
+
exports.getArg = getArg;
|
|
7
|
+
exports.createServer = createServer;
|
|
8
|
+
const node_http_1 = require("node:http");
|
|
9
|
+
var generated_js_1 = require("./generated.js");
|
|
10
|
+
Object.defineProperty(exports, "PACKAGE_NAME", { enumerable: true, get: function () { return generated_js_1.PACKAGE_NAME; } });
|
|
11
|
+
Object.defineProperty(exports, "PACKAGE_VERSION", { enumerable: true, get: function () { return generated_js_1.PACKAGE_VERSION; } });
|
|
12
|
+
exports.DEFAULT_HOST = "127.0.0.1";
|
|
13
|
+
exports.DEFAULT_PORT = 6270;
|
|
14
|
+
exports.DEFAULT_PAGES_URL = "https://mcp-proxy.pages.dev";
|
|
15
|
+
// JSON-RPC error codes (-32000 to -32099 = server-defined, -32603 = spec internal error)
|
|
16
|
+
exports.ErrorCode = {
|
|
17
|
+
INTERNAL: -32603, // JSON-RPC spec: internal error
|
|
18
|
+
PROXY_NOT_CONFIGURED: -32001, // Proxy has not been paired yet
|
|
19
|
+
HOST_UNREACHABLE: -32002, // Cannot reach the host agent via tunnel
|
|
20
|
+
PROCESS_EXITED: -32003, // MCP server child process exited unexpectedly
|
|
21
|
+
PROCESS_NOT_RUNNING: -32004, // MCP server child process is not running
|
|
22
|
+
REQUEST_TIMEOUT: -32005, // MCP server did not respond in time
|
|
23
|
+
};
|
|
24
|
+
exports.ErrorMessage = {
|
|
25
|
+
[exports.ErrorCode.INTERNAL]: "Internal error",
|
|
26
|
+
[exports.ErrorCode.PROXY_NOT_CONFIGURED]: "Proxy not configured",
|
|
27
|
+
[exports.ErrorCode.HOST_UNREACHABLE]: "Host agent unreachable",
|
|
28
|
+
[exports.ErrorCode.PROCESS_EXITED]: "Server process exited",
|
|
29
|
+
[exports.ErrorCode.PROCESS_NOT_RUNNING]: "Server process not running",
|
|
30
|
+
[exports.ErrorCode.REQUEST_TIMEOUT]: "Request timed out",
|
|
31
|
+
};
|
|
32
|
+
// JSON-RPC error response helper
|
|
33
|
+
function jsonRpcError(code, detail, id = null) {
|
|
34
|
+
const base = exports.ErrorMessage[code] ?? "Unknown error";
|
|
35
|
+
const message = detail ? `${base}: ${detail}` : base;
|
|
36
|
+
return JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id });
|
|
37
|
+
}
|
|
38
|
+
// Read full request body as string
|
|
39
|
+
function readBody(req) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const chunks = [];
|
|
42
|
+
req.on("data", (c) => chunks.push(c));
|
|
43
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
44
|
+
req.on("error", reject);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
// Parse CLI argument by name: --flag value
|
|
48
|
+
function getArg(name) {
|
|
49
|
+
const idx = process.argv.indexOf(name);
|
|
50
|
+
return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : undefined;
|
|
51
|
+
}
|
|
52
|
+
// Create HTTP server with async handler and error catching
|
|
53
|
+
function createServer(handler) {
|
|
54
|
+
return (0, node_http_1.createServer)((req, res) => {
|
|
55
|
+
handler(req, res).catch((err) => {
|
|
56
|
+
console.error(`Request handler error: ${err.message}`);
|
|
57
|
+
if (!res.headersSent) {
|
|
58
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
59
|
+
res.end(jsonRpcError(exports.ErrorCode.INTERNAL));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
// Line-buffered reader: accumulates chunks and yields complete lines
|
|
65
|
+
class LineBuffer {
|
|
66
|
+
buffer = "";
|
|
67
|
+
push(chunk) {
|
|
68
|
+
this.buffer += chunk;
|
|
69
|
+
const parts = this.buffer.split("\n");
|
|
70
|
+
this.buffer = parts.pop(); // Keep incomplete trailing segment
|
|
71
|
+
return parts.filter((line) => line.trim().length > 0);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.LineBuffer = LineBuffer;
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@silver886/mcp-proxy",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "MCP proxy bridge: forward MCP requests across network boundaries via Cloudflare tunnel",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"proxy",
|
|
10
|
+
"tunnel",
|
|
11
|
+
"cloudflare-tunnel",
|
|
12
|
+
"remote",
|
|
13
|
+
"wsl",
|
|
14
|
+
"docker"
|
|
15
|
+
],
|
|
16
|
+
"bin": {
|
|
17
|
+
"host": "mcp/dist/host.js",
|
|
18
|
+
"proxy": "mcp/dist/proxy.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"mcp/dist"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"cloudflared": "^0.7.1"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "pnpm --filter @silver886/mcp-proxy-mcp build",
|
|
28
|
+
"deploy:pages": "pnpm --filter @silver886/mcp-proxy-pages run deploy"
|
|
29
|
+
}
|
|
30
|
+
}
|