@silver886/mcp-proxy 0.1.3 → 0.2.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 +62 -17
- package/dist/host/agent.d.ts +22 -0
- package/dist/host/agent.js +314 -0
- package/dist/host/cli.d.ts +1 -0
- package/dist/host/cli.js +83 -0
- package/dist/host/constants.d.ts +4 -0
- package/dist/host/constants.js +16 -0
- package/dist/host/session.d.ts +21 -0
- package/dist/host/session.js +204 -0
- package/dist/host/tunnel.d.ts +5 -0
- package/dist/host/tunnel.js +82 -0
- package/dist/host.js +8 -0
- package/dist/proxy/core/constants.d.ts +13 -0
- package/dist/proxy/core/constants.js +39 -0
- package/dist/proxy/core/fetch-timeout.d.ts +1 -0
- package/dist/proxy/core/fetch-timeout.js +15 -0
- package/dist/proxy/core/state.d.ts +25 -0
- package/dist/proxy/core/state.js +90 -0
- package/dist/proxy/core/types.d.ts +57 -0
- package/dist/proxy/core/types.js +5 -0
- package/dist/proxy/discovery/client.d.ts +42 -0
- package/dist/proxy/discovery/client.js +283 -0
- package/dist/proxy/discovery/runner.d.ts +21 -0
- package/dist/proxy/discovery/runner.js +319 -0
- package/dist/proxy/pairing/config.d.ts +9 -0
- package/dist/proxy/pairing/config.js +130 -0
- package/dist/proxy/pairing/controller.d.ts +19 -0
- package/dist/proxy/pairing/controller.js +327 -0
- package/dist/proxy/pairing/http.d.ts +70 -0
- package/dist/proxy/pairing/http.js +155 -0
- package/dist/proxy/pairing/static-assets.d.ts +4 -0
- package/dist/proxy/pairing/static-assets.js +13 -0
- package/dist/proxy/pairing/tunnel.d.ts +13 -0
- package/dist/proxy/pairing/tunnel.js +130 -0
- package/dist/proxy/pairing/validation.d.ts +2 -0
- package/dist/proxy/pairing/validation.js +62 -0
- package/dist/proxy/routing/filtering.d.ts +13 -0
- package/dist/proxy/routing/filtering.js +116 -0
- package/dist/proxy/routing/router.d.ts +17 -0
- package/dist/proxy/routing/router.js +74 -0
- package/dist/proxy/routing/uri.d.ts +7 -0
- package/dist/proxy/routing/uri.js +39 -0
- package/dist/proxy/runtime/forwarder.d.ts +15 -0
- package/dist/proxy/runtime/forwarder.js +265 -0
- package/dist/proxy/runtime/handlers.d.ts +48 -0
- package/dist/proxy/runtime/handlers.js +329 -0
- package/dist/proxy/runtime/sse.d.ts +19 -0
- package/dist/proxy/runtime/sse.js +169 -0
- package/dist/proxy/runtime/upstream-bridge.d.ts +27 -0
- package/dist/proxy/runtime/upstream-bridge.js +133 -0
- package/dist/proxy/server.d.ts +15 -0
- package/dist/proxy/server.js +167 -0
- package/dist/proxy.js +5 -0
- package/{mcp/dist → dist}/shared/protocol.d.ts +15 -3
- package/dist/shared/protocol.js +183 -0
- package/dist/wrapper.d.ts +2 -0
- package/dist/wrapper.js +72 -0
- package/package.json +15 -7
- package/static/setup.css +233 -0
- package/static/setup.html +57 -0
- package/static/setup.js +711 -0
- package/static/style.css +208 -0
- package/mcp/dist/host.js +0 -307
- package/mcp/dist/proxy.js +0 -374
- package/mcp/dist/shared/generated.d.ts +0 -2
- package/mcp/dist/shared/generated.js +0 -5
- package/mcp/dist/shared/protocol.js +0 -79
- /package/{mcp/dist → dist}/host.d.ts +0 -0
- /package/{mcp/dist → dist}/proxy.d.ts +0 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SERVER_NAME_PATTERN = exports.TOOL_NAME_SEPARATOR = exports.LineBuffer = exports.ErrorMessage = exports.ErrorCode = exports.BodyTooLargeError = exports.MAX_BODY_BYTES = exports.DEFAULT_PORT = exports.DEFAULT_HOST = exports.MCP_PROTOCOL_VERSION = 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
|
+
exports.validateServerName = validateServerName;
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const node_http_1 = require("node:http");
|
|
11
|
+
const node_path_1 = require("node:path");
|
|
12
|
+
// dist/shared/protocol.js → ../../package.json (project root, same as the
|
|
13
|
+
// installed npm package's root). package.json is always shipped in the
|
|
14
|
+
// tarball regardless of the `files` whitelist, so this works in both
|
|
15
|
+
// local dev and installed contexts.
|
|
16
|
+
const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.resolve)(__dirname, "..", "..", "package.json"), "utf-8"));
|
|
17
|
+
exports.PACKAGE_NAME = pkg.name;
|
|
18
|
+
exports.PACKAGE_VERSION = pkg.version;
|
|
19
|
+
exports.MCP_PROTOCOL_VERSION = "2024-11-05";
|
|
20
|
+
exports.DEFAULT_HOST = "127.0.0.1";
|
|
21
|
+
exports.DEFAULT_PORT = 6270;
|
|
22
|
+
// Cap on the size of any single inbound HTTP request body. Both the
|
|
23
|
+
// pairing endpoints and the host agent sit behind a Cloudflare tunnel
|
|
24
|
+
// gated by a bearer token; without a cap, a leaked token gives an
|
|
25
|
+
// attacker a trivial memory-DoS by streaming an arbitrarily large body.
|
|
26
|
+
// 4 MiB is comfortably above any plausible MCP JSON-RPC request (tool
|
|
27
|
+
// args, init handshakes) while keeping worst-case memory bounded.
|
|
28
|
+
exports.MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
29
|
+
class BodyTooLargeError extends Error {
|
|
30
|
+
limit;
|
|
31
|
+
constructor(limit) {
|
|
32
|
+
super(`request body exceeds ${limit} bytes`);
|
|
33
|
+
this.limit = limit;
|
|
34
|
+
this.name = "BodyTooLargeError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
exports.BodyTooLargeError = BodyTooLargeError;
|
|
38
|
+
// JSON-RPC error codes: -32700/-32600..-32603 = spec-defined, -32000..-32099 = server-defined
|
|
39
|
+
exports.ErrorCode = {
|
|
40
|
+
PARSE_ERROR: -32700, // JSON-RPC spec: invalid JSON received
|
|
41
|
+
INVALID_REQUEST: -32600, // JSON-RPC spec: malformed request envelope
|
|
42
|
+
METHOD_NOT_FOUND: -32601, // JSON-RPC spec: method not found
|
|
43
|
+
INVALID_PARAMS: -32602, // JSON-RPC spec: invalid params
|
|
44
|
+
INTERNAL: -32603, // JSON-RPC spec: internal error
|
|
45
|
+
PROXY_NOT_CONFIGURED: -32001, // Proxy has not been paired yet
|
|
46
|
+
HOST_UNREACHABLE: -32002, // Cannot reach the host agent via tunnel
|
|
47
|
+
PROCESS_EXITED: -32003, // MCP server child process exited unexpectedly
|
|
48
|
+
PROCESS_NOT_RUNNING: -32004, // MCP server child process is not running
|
|
49
|
+
REQUEST_TIMEOUT: -32005, // MCP server did not respond in time
|
|
50
|
+
};
|
|
51
|
+
exports.ErrorMessage = {
|
|
52
|
+
[exports.ErrorCode.PARSE_ERROR]: "Parse error",
|
|
53
|
+
[exports.ErrorCode.INVALID_REQUEST]: "Invalid request",
|
|
54
|
+
[exports.ErrorCode.METHOD_NOT_FOUND]: "Method not found",
|
|
55
|
+
[exports.ErrorCode.INVALID_PARAMS]: "Invalid params",
|
|
56
|
+
[exports.ErrorCode.INTERNAL]: "Internal error",
|
|
57
|
+
[exports.ErrorCode.PROXY_NOT_CONFIGURED]: "Proxy not configured",
|
|
58
|
+
[exports.ErrorCode.HOST_UNREACHABLE]: "Host agent unreachable",
|
|
59
|
+
[exports.ErrorCode.PROCESS_EXITED]: "Server process exited",
|
|
60
|
+
[exports.ErrorCode.PROCESS_NOT_RUNNING]: "Server process not running",
|
|
61
|
+
[exports.ErrorCode.REQUEST_TIMEOUT]: "Request timed out",
|
|
62
|
+
};
|
|
63
|
+
// JSON-RPC error response helper
|
|
64
|
+
function jsonRpcError(code, detail, id = null) {
|
|
65
|
+
const base = exports.ErrorMessage[code] ?? "Unknown error";
|
|
66
|
+
const message = detail ? `${base}: ${detail}` : base;
|
|
67
|
+
return JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id });
|
|
68
|
+
}
|
|
69
|
+
// Read full request body as string, rejecting with BodyTooLargeError once
|
|
70
|
+
// the running total exceeds maxBytes. We pause the request once over the
|
|
71
|
+
// limit so we stop accumulating into memory; the socket is torn down by
|
|
72
|
+
// createServer's 413 path after the response flushes (destroying it here
|
|
73
|
+
// races the response and the client never sees the 413).
|
|
74
|
+
//
|
|
75
|
+
// Settles on the first of: end (resolve), error (reject), close-without-end
|
|
76
|
+
// (reject as ECONNRESET-style), or oversize (reject as BodyTooLargeError).
|
|
77
|
+
// All four listeners are torn down on settle so a paused/aborted upload
|
|
78
|
+
// can't leave the closure (and the accumulated chunks) pinned in memory.
|
|
79
|
+
function readBody(req, maxBytes = exports.MAX_BODY_BYTES) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const chunks = [];
|
|
82
|
+
let total = 0;
|
|
83
|
+
let settled = false;
|
|
84
|
+
const cleanup = () => {
|
|
85
|
+
req.off("data", onData);
|
|
86
|
+
req.off("end", onEnd);
|
|
87
|
+
req.off("error", onError);
|
|
88
|
+
req.off("close", onClose);
|
|
89
|
+
};
|
|
90
|
+
const settleResolve = (value) => {
|
|
91
|
+
if (settled)
|
|
92
|
+
return;
|
|
93
|
+
settled = true;
|
|
94
|
+
cleanup();
|
|
95
|
+
resolve(value);
|
|
96
|
+
};
|
|
97
|
+
const settleReject = (err) => {
|
|
98
|
+
if (settled)
|
|
99
|
+
return;
|
|
100
|
+
settled = true;
|
|
101
|
+
cleanup();
|
|
102
|
+
reject(err);
|
|
103
|
+
};
|
|
104
|
+
const onData = (c) => {
|
|
105
|
+
if (settled)
|
|
106
|
+
return;
|
|
107
|
+
total += c.length;
|
|
108
|
+
if (total > maxBytes) {
|
|
109
|
+
// Pause so we stop accumulating; rejection runs the 413 path in
|
|
110
|
+
// createServer, which destroys the socket after the response flushes.
|
|
111
|
+
req.pause();
|
|
112
|
+
settleReject(new BodyTooLargeError(maxBytes));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
chunks.push(c);
|
|
116
|
+
};
|
|
117
|
+
const onEnd = () => settleResolve(Buffer.concat(chunks).toString("utf-8"));
|
|
118
|
+
const onError = (err) => settleReject(err);
|
|
119
|
+
// 'close' fires after end OR after an abort. If end ran first we're
|
|
120
|
+
// already settled and this is a no-op; otherwise the client disconnected
|
|
121
|
+
// mid-upload and we must reject so the handler doesn't hang forever.
|
|
122
|
+
const onClose = () => settleReject(new Error("client closed connection before request body completed"));
|
|
123
|
+
req.on("data", onData);
|
|
124
|
+
req.on("end", onEnd);
|
|
125
|
+
req.on("error", onError);
|
|
126
|
+
req.on("close", onClose);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// Parse CLI argument by name: --flag value
|
|
130
|
+
function getArg(name) {
|
|
131
|
+
const idx = process.argv.indexOf(name);
|
|
132
|
+
return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : undefined;
|
|
133
|
+
}
|
|
134
|
+
// Create HTTP server with async handler and error catching. BodyTooLargeError
|
|
135
|
+
// is special-cased to 413 + Connection: close so the client sees a clean
|
|
136
|
+
// "payload too large" instead of the catch-all 500. After the response
|
|
137
|
+
// flushes we destroy the socket so an attacker can't keep streaming bytes
|
|
138
|
+
// into the kernel buffer beyond the limit we just enforced.
|
|
139
|
+
function createServer(handler) {
|
|
140
|
+
return (0, node_http_1.createServer)((req, res) => {
|
|
141
|
+
handler(req, res).catch((err) => {
|
|
142
|
+
console.error(`Request handler error: ${err.message}`);
|
|
143
|
+
if (!res.headersSent) {
|
|
144
|
+
if (err instanceof BodyTooLargeError) {
|
|
145
|
+
res.writeHead(413, { "Content-Type": "application/json", Connection: "close" });
|
|
146
|
+
res.end(JSON.stringify({ error: err.message }), () => {
|
|
147
|
+
req.socket?.destroy();
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
152
|
+
res.end(jsonRpcError(exports.ErrorCode.INTERNAL));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// Line-buffered reader: accumulates chunks and yields complete lines
|
|
158
|
+
class LineBuffer {
|
|
159
|
+
buffer = "";
|
|
160
|
+
push(chunk) {
|
|
161
|
+
this.buffer += chunk;
|
|
162
|
+
const parts = this.buffer.split("\n");
|
|
163
|
+
this.buffer = parts.pop(); // Keep incomplete trailing segment
|
|
164
|
+
return parts.filter((line) => line.trim().length > 0);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
exports.LineBuffer = LineBuffer;
|
|
168
|
+
// Single source of truth for the server-name policy enforced everywhere
|
|
169
|
+
// (host config load, proxy discovery filter, and the Pages function path
|
|
170
|
+
// allowlist). Keeping these in lockstep prevents valid host config entries
|
|
171
|
+
// from silently disappearing during discovery.
|
|
172
|
+
exports.TOOL_NAME_SEPARATOR = "__";
|
|
173
|
+
exports.SERVER_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
174
|
+
// Returns null if the name is acceptable, else a human-readable reason.
|
|
175
|
+
function validateServerName(name) {
|
|
176
|
+
if (!exports.SERVER_NAME_PATTERN.test(name)) {
|
|
177
|
+
return `must match ${exports.SERVER_NAME_PATTERN} (letters, digits, '.', '_', '-')`;
|
|
178
|
+
}
|
|
179
|
+
if (name.includes(exports.TOOL_NAME_SEPARATOR)) {
|
|
180
|
+
return `must not contain '${exports.TOOL_NAME_SEPARATOR}' (reserved as tool-name separator)`;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
package/dist/wrapper.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
// Cloudflared wrapper. Owns the cloudflared child for the proxy's pairing
|
|
5
|
+
// tunnel, and guarantees the child cannot outlive its parent (proxy).
|
|
6
|
+
//
|
|
7
|
+
// Lifecycle protocol on stdio:
|
|
8
|
+
// parent -> wrapper: writes "stop\n" to request a clean shutdown
|
|
9
|
+
// parent dies : stdin closes, wrapper sees EOF and tears the child down
|
|
10
|
+
// wrapper -> parent: prints "URL <tunnel-url>\n" once the tunnel is ready
|
|
11
|
+
// wrapper -> parent: prints "ERR <message>\n" for each cloudflared error
|
|
12
|
+
// so the parent can include the actual cause in any
|
|
13
|
+
// rejection / unexpected-exit log instead of a generic
|
|
14
|
+
// "exited before becoming ready"
|
|
15
|
+
// wrapper -> parent: prints "EXIT <code>\n" when cloudflared exits
|
|
16
|
+
//
|
|
17
|
+
// Detection latency for parent death is 0ms on Linux/macOS/Windows because
|
|
18
|
+
// stdin EOF is delivered by the kernel at the moment the parent's pipe FD
|
|
19
|
+
// is closed; no polling is needed.
|
|
20
|
+
const cloudflared_1 = require("cloudflared");
|
|
21
|
+
const protocol_js_1 = require("./shared/protocol.js");
|
|
22
|
+
function main() {
|
|
23
|
+
const port = parseInt(process.argv[2] ?? "", 10);
|
|
24
|
+
if (!port || Number.isNaN(port)) {
|
|
25
|
+
process.stderr.write("Usage: wrapper.js <port>\n");
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
const tunnel = cloudflared_1.Tunnel.quick(`http://localhost:${port}`);
|
|
29
|
+
let stopping = false;
|
|
30
|
+
const stop = (code = 0) => {
|
|
31
|
+
if (stopping)
|
|
32
|
+
return;
|
|
33
|
+
stopping = true;
|
|
34
|
+
try {
|
|
35
|
+
tunnel.stop();
|
|
36
|
+
}
|
|
37
|
+
catch { /* already stopped */ }
|
|
38
|
+
// Give cloudflared a moment to flush, then exit.
|
|
39
|
+
setTimeout(() => process.exit(code), 250).unref();
|
|
40
|
+
};
|
|
41
|
+
tunnel.once("url", (url) => {
|
|
42
|
+
process.stdout.write(`URL ${url}\n`);
|
|
43
|
+
});
|
|
44
|
+
tunnel.on("error", (err) => {
|
|
45
|
+
// Forward to parent on the structured channel (stdout) so PairingTunnel
|
|
46
|
+
// can include the cause in its rejection. Also keep the human-readable
|
|
47
|
+
// line on stderr — stdio is inherited, so operators tailing logs still
|
|
48
|
+
// see the full cloudflared diagnostic context.
|
|
49
|
+
const message = err.message.replace(/\r?\n/g, " ");
|
|
50
|
+
process.stdout.write(`ERR ${message}\n`);
|
|
51
|
+
process.stderr.write(`tunnel error: ${err.message}\n`);
|
|
52
|
+
});
|
|
53
|
+
tunnel.on("exit", (code) => {
|
|
54
|
+
process.stdout.write(`EXIT ${code ?? ""}\n`);
|
|
55
|
+
stop(typeof code === "number" ? code : 0);
|
|
56
|
+
});
|
|
57
|
+
// Detect parent death via stdin EOF; also accept a "stop" line for clean
|
|
58
|
+
// shutdown initiated by the parent after pairing completes.
|
|
59
|
+
const buf = new protocol_js_1.LineBuffer();
|
|
60
|
+
process.stdin.setEncoding("utf-8");
|
|
61
|
+
process.stdin.on("data", (chunk) => {
|
|
62
|
+
for (const line of buf.push(chunk)) {
|
|
63
|
+
if (line.trim() === "stop")
|
|
64
|
+
stop(0);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
process.stdin.on("end", () => stop(0));
|
|
68
|
+
process.stdin.on("close", () => stop(0));
|
|
69
|
+
process.on("SIGINT", () => stop(0));
|
|
70
|
+
process.on("SIGTERM", () => stop(0));
|
|
71
|
+
}
|
|
72
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@silver886/mcp-proxy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "MCP proxy bridge: forward MCP requests across network boundaries via Cloudflare tunnel",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,17 +26,25 @@
|
|
|
26
26
|
},
|
|
27
27
|
"homepage": "https://github.com/silver886/mcp-proxy#readme",
|
|
28
28
|
"bin": {
|
|
29
|
-
"host": "
|
|
30
|
-
"proxy": "
|
|
29
|
+
"host": "dist/host.js",
|
|
30
|
+
"proxy": "dist/proxy.js"
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
|
-
"
|
|
33
|
+
"dist",
|
|
34
|
+
"static"
|
|
34
35
|
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20.3.0"
|
|
38
|
+
},
|
|
35
39
|
"dependencies": {
|
|
36
|
-
"cloudflared": "^0.7.1"
|
|
40
|
+
"cloudflared": "^0.7.1",
|
|
41
|
+
"eventsource-parser": "^3.0.8"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^20.0.0",
|
|
45
|
+
"typescript": "^5.5.0"
|
|
37
46
|
},
|
|
38
47
|
"scripts": {
|
|
39
|
-
"build": "
|
|
40
|
-
"deploy:pages": "pnpm --filter @silver886/mcp-proxy-pages run deploy"
|
|
48
|
+
"build": "tsc"
|
|
41
49
|
}
|
|
42
50
|
}
|
package/static/setup.css
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/* Setup page layout. Base styles (button, banner, hint, card, etc.) live in
|
|
2
|
+
style.css; this file owns layout that's specific to the pairing flow:
|
|
3
|
+
host rows in step 1, server/tool selection in step 2. */
|
|
4
|
+
|
|
5
|
+
.step {
|
|
6
|
+
display: none;
|
|
7
|
+
}
|
|
8
|
+
.step.active {
|
|
9
|
+
display: block;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* Hosts flow as a grid so wider viewports can show multiple hosts side by
|
|
13
|
+
side. minmax(280px, 1fr) collapses to a single column on mobile (one
|
|
14
|
+
host = full width) and auto-fits extra columns when the card grows on
|
|
15
|
+
tablet/desktop. auto-fit (not auto-fill) collapses empty tracks so a
|
|
16
|
+
single host stretches to the full row instead of leaving a gap. */
|
|
17
|
+
#hosts-container {
|
|
18
|
+
display: grid;
|
|
19
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
20
|
+
gap: 0.875rem;
|
|
21
|
+
margin-top: 0.875rem;
|
|
22
|
+
}
|
|
23
|
+
.host-row {
|
|
24
|
+
border: 1px solid #334155;
|
|
25
|
+
border-radius: 8px;
|
|
26
|
+
padding: 0.875rem 1rem 1rem;
|
|
27
|
+
}
|
|
28
|
+
.host-row .host-head {
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
justify-content: space-between;
|
|
32
|
+
gap: 0.5rem;
|
|
33
|
+
}
|
|
34
|
+
.host-row .host-id {
|
|
35
|
+
font-weight: 600;
|
|
36
|
+
color: #38bdf8;
|
|
37
|
+
font-size: 0.95rem;
|
|
38
|
+
}
|
|
39
|
+
.host-row .host-remove {
|
|
40
|
+
background: transparent;
|
|
41
|
+
color: #f87171;
|
|
42
|
+
border: 1px solid #4b5563;
|
|
43
|
+
border-radius: 6px;
|
|
44
|
+
padding: 0.25rem 0.5rem;
|
|
45
|
+
font-size: 0.75rem;
|
|
46
|
+
width: auto;
|
|
47
|
+
margin: 0;
|
|
48
|
+
}
|
|
49
|
+
.host-row .host-remove:hover {
|
|
50
|
+
background: #3f1d1d;
|
|
51
|
+
}
|
|
52
|
+
.host-row label {
|
|
53
|
+
margin-top: 0.625rem;
|
|
54
|
+
}
|
|
55
|
+
.host-row .host-status {
|
|
56
|
+
font-size: 0.75rem;
|
|
57
|
+
margin-top: 0.5rem;
|
|
58
|
+
color: #94a3b8;
|
|
59
|
+
}
|
|
60
|
+
.host-row .host-status.error {
|
|
61
|
+
color: #fca5a5;
|
|
62
|
+
}
|
|
63
|
+
.host-row .host-status.partial {
|
|
64
|
+
color: #fbbf24;
|
|
65
|
+
}
|
|
66
|
+
.host-row .host-status.ok {
|
|
67
|
+
color: #6ee7b7;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#add-host-btn {
|
|
71
|
+
background: #334155;
|
|
72
|
+
margin-top: 0.75rem;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.step2-actions {
|
|
76
|
+
display: flex;
|
|
77
|
+
gap: 0.5rem;
|
|
78
|
+
margin-top: 1.5rem;
|
|
79
|
+
}
|
|
80
|
+
.step2-actions button {
|
|
81
|
+
margin-top: 0;
|
|
82
|
+
width: auto;
|
|
83
|
+
}
|
|
84
|
+
.step2-actions #back-btn {
|
|
85
|
+
flex: 0 0 auto;
|
|
86
|
+
background: #334155;
|
|
87
|
+
}
|
|
88
|
+
.step2-actions #save-btn {
|
|
89
|
+
flex: 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.host-block {
|
|
93
|
+
margin-top: 1.25rem;
|
|
94
|
+
padding-top: 0.75rem;
|
|
95
|
+
border-top: 1px solid #334155;
|
|
96
|
+
}
|
|
97
|
+
.host-block:first-of-type {
|
|
98
|
+
border-top: none;
|
|
99
|
+
margin-top: 0;
|
|
100
|
+
padding-top: 0;
|
|
101
|
+
}
|
|
102
|
+
.host-block > .host-block-head {
|
|
103
|
+
font-size: 0.8rem;
|
|
104
|
+
color: #94a3b8;
|
|
105
|
+
text-transform: uppercase;
|
|
106
|
+
letter-spacing: 0.04em;
|
|
107
|
+
margin-bottom: 0.25rem;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.server-group {
|
|
111
|
+
margin-top: 1rem;
|
|
112
|
+
padding-left: 0.25rem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* On wider viewports, lay each host's server groups out as a grid so
|
|
116
|
+
multiple servers can sit side by side. auto-fit collapses empty tracks
|
|
117
|
+
so a host with one server still stretches edge-to-edge. The block-head
|
|
118
|
+
spans the full width because it's the host-id label, not a server. */
|
|
119
|
+
@media (min-width: 768px) {
|
|
120
|
+
.host-block {
|
|
121
|
+
display: grid;
|
|
122
|
+
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
123
|
+
gap: 1rem;
|
|
124
|
+
align-items: start;
|
|
125
|
+
}
|
|
126
|
+
.host-block > .host-block-head {
|
|
127
|
+
grid-column: 1 / -1;
|
|
128
|
+
}
|
|
129
|
+
.server-group {
|
|
130
|
+
margin-top: 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
.server-group h3 {
|
|
134
|
+
font-size: 0.95rem;
|
|
135
|
+
color: #e2e8f0;
|
|
136
|
+
word-break: break-all;
|
|
137
|
+
}
|
|
138
|
+
.server-group h3 .scope {
|
|
139
|
+
color: #38bdf8;
|
|
140
|
+
font-weight: 500;
|
|
141
|
+
}
|
|
142
|
+
.server-group .server-counts {
|
|
143
|
+
color: #64748b;
|
|
144
|
+
font-size: 0.8rem;
|
|
145
|
+
margin-top: 0.125rem;
|
|
146
|
+
margin-bottom: 0.5rem;
|
|
147
|
+
}
|
|
148
|
+
.server-group.disabled .tool-list {
|
|
149
|
+
opacity: 0.4;
|
|
150
|
+
pointer-events: none;
|
|
151
|
+
}
|
|
152
|
+
.server-group.disabled .select-actions {
|
|
153
|
+
opacity: 0.4;
|
|
154
|
+
pointer-events: none;
|
|
155
|
+
}
|
|
156
|
+
.server-toggle {
|
|
157
|
+
display: flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
gap: 0.5rem;
|
|
160
|
+
font-size: 0.85rem;
|
|
161
|
+
color: #cbd5e1;
|
|
162
|
+
margin-bottom: 0.5rem;
|
|
163
|
+
}
|
|
164
|
+
.server-toggle input[type='checkbox'] {
|
|
165
|
+
accent-color: #38bdf8;
|
|
166
|
+
cursor: pointer;
|
|
167
|
+
width: auto;
|
|
168
|
+
flex: 0 0 auto;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.tool-list {
|
|
172
|
+
max-height: 280px;
|
|
173
|
+
overflow-y: auto;
|
|
174
|
+
border: 1px solid #334155;
|
|
175
|
+
border-radius: 6px;
|
|
176
|
+
}
|
|
177
|
+
.tool-item {
|
|
178
|
+
display: flex;
|
|
179
|
+
align-items: center;
|
|
180
|
+
border-bottom: 1px solid #1e293b;
|
|
181
|
+
}
|
|
182
|
+
.tool-item:last-child {
|
|
183
|
+
border-bottom: none;
|
|
184
|
+
}
|
|
185
|
+
.tool-item:hover {
|
|
186
|
+
background: #253348;
|
|
187
|
+
}
|
|
188
|
+
.tool-check {
|
|
189
|
+
display: flex;
|
|
190
|
+
align-items: center;
|
|
191
|
+
justify-content: center;
|
|
192
|
+
padding: 0.5rem 0.625rem;
|
|
193
|
+
flex-shrink: 0;
|
|
194
|
+
}
|
|
195
|
+
.tool-check input[type='checkbox'] {
|
|
196
|
+
accent-color: #38bdf8;
|
|
197
|
+
cursor: pointer;
|
|
198
|
+
}
|
|
199
|
+
.tool-label {
|
|
200
|
+
display: flex;
|
|
201
|
+
flex-direction: column;
|
|
202
|
+
justify-content: center;
|
|
203
|
+
gap: 0.125rem;
|
|
204
|
+
margin: 0;
|
|
205
|
+
padding: 0.5rem 0.75rem 0.5rem 0;
|
|
206
|
+
cursor: pointer;
|
|
207
|
+
font-size: 0.85rem;
|
|
208
|
+
flex: 1;
|
|
209
|
+
min-width: 0;
|
|
210
|
+
}
|
|
211
|
+
.tool-name {
|
|
212
|
+
font-weight: 600;
|
|
213
|
+
color: #e2e8f0;
|
|
214
|
+
}
|
|
215
|
+
.tool-desc {
|
|
216
|
+
font-size: 0.75rem;
|
|
217
|
+
color: #64748b;
|
|
218
|
+
line-height: 1.3;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.select-actions {
|
|
222
|
+
display: flex;
|
|
223
|
+
gap: 0.5rem;
|
|
224
|
+
margin-top: 0.5rem;
|
|
225
|
+
margin-bottom: 0.5rem;
|
|
226
|
+
}
|
|
227
|
+
.select-actions button {
|
|
228
|
+
flex: 1;
|
|
229
|
+
padding: 0.375rem;
|
|
230
|
+
font-size: 0.8rem;
|
|
231
|
+
background: #334155;
|
|
232
|
+
margin-top: 0;
|
|
233
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>MCP Proxy – Setup</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css" />
|
|
8
|
+
<link rel="stylesheet" href="/setup.css" />
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div class="card">
|
|
12
|
+
<h1>MCP Proxy Setup</h1>
|
|
13
|
+
|
|
14
|
+
<div id="step1" class="step active">
|
|
15
|
+
<p class="desc">
|
|
16
|
+
Add one or more host agents. Each host has its own tunnel URL and
|
|
17
|
+
auth token. Tools will be namespaced by host id when exposed to
|
|
18
|
+
the MCP client.
|
|
19
|
+
</p>
|
|
20
|
+
<form id="tunnel-form">
|
|
21
|
+
<div id="hosts-container"></div>
|
|
22
|
+
<button type="button" id="add-host-btn">
|
|
23
|
+
+ Add another host
|
|
24
|
+
</button>
|
|
25
|
+
<button type="submit" id="tunnel-btn">Discover Servers</button>
|
|
26
|
+
</form>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div id="step2" class="step">
|
|
30
|
+
<p class="desc">
|
|
31
|
+
Select the servers and tools to expose through the proxy.
|
|
32
|
+
Unchecking a server hides every capability it offers — tools,
|
|
33
|
+
prompts, and resources alike.
|
|
34
|
+
</p>
|
|
35
|
+
<div id="servers-container"></div>
|
|
36
|
+
<p id="save-hint" class="hint">
|
|
37
|
+
Select at least one server to continue.
|
|
38
|
+
</p>
|
|
39
|
+
<div class="step2-actions">
|
|
40
|
+
<button type="button" id="back-btn">← Back</button>
|
|
41
|
+
<button id="save-btn" disabled>Complete Setup</button>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div id="result-section" style="display: none"></div>
|
|
46
|
+
|
|
47
|
+
<div id="error-section" style="display: none">
|
|
48
|
+
<div class="banner error">
|
|
49
|
+
Invalid setup link. The URL must contain the proxy's bearer token
|
|
50
|
+
in the hash fragment (e.g. <code>/#token=...</code>).
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<script src="/setup.js"></script>
|
|
56
|
+
</body>
|
|
57
|
+
</html>
|