@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 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
+ ```
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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,2 @@
1
+ export declare const PACKAGE_NAME = "@silver886/mcp-proxy";
2
+ export declare const PACKAGE_VERSION = "0.1.1";
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PACKAGE_VERSION = exports.PACKAGE_NAME = void 0;
4
+ exports.PACKAGE_NAME = "@silver886/mcp-proxy";
5
+ exports.PACKAGE_VERSION = "0.1.1";
@@ -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
+ }