@silver886/mcp-proxy 0.1.4 → 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 -377
- 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,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.McpSession = void 0;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const protocol_js_1 = require("../shared/protocol.js");
|
|
6
|
+
const constants_js_1 = require("./constants.js");
|
|
7
|
+
// One McpSession owns a single MCP server child process and matches its
|
|
8
|
+
// JSON-RPC stdout responses to outstanding requests. Notifications (no id)
|
|
9
|
+
// are queued for the SSE poller in HostAgent. Lifetime is tied to the host's
|
|
10
|
+
// session map: HostAgent.sweepIdleSessions reaps after SESSION_IDLE_TIMEOUT_MS,
|
|
11
|
+
// shutdown destroys all entries, and stdin/process errors fail every pending
|
|
12
|
+
// request so callers don't sit through their per-request timeout.
|
|
13
|
+
class McpSession {
|
|
14
|
+
name;
|
|
15
|
+
timeout;
|
|
16
|
+
process;
|
|
17
|
+
stdoutBuffer = new protocol_js_1.LineBuffer();
|
|
18
|
+
pending = new Map();
|
|
19
|
+
notifications = [];
|
|
20
|
+
notificationsDropped = 0;
|
|
21
|
+
// Late responses to already-timed-out requests: counted separately so the
|
|
22
|
+
// drain log can attribute "child answered after we gave up" distinctly
|
|
23
|
+
// from notification-queue overflow.
|
|
24
|
+
orphansDropped = 0;
|
|
25
|
+
destroyed = false;
|
|
26
|
+
lastActivity = Date.now();
|
|
27
|
+
constructor(name, config, timeout) {
|
|
28
|
+
this.name = name;
|
|
29
|
+
this.timeout = timeout;
|
|
30
|
+
console.log(`[${name}] Spawning: ${config.command} ${config.args.join(" ")}`);
|
|
31
|
+
this.process = (0, node_child_process_1.spawn)(config.command, config.args, {
|
|
32
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
33
|
+
env: { ...process.env, ...config.env },
|
|
34
|
+
shell: config.shell ?? false,
|
|
35
|
+
});
|
|
36
|
+
this.process.stdout.on("data", (chunk) => {
|
|
37
|
+
const lines = this.stdoutBuffer.push(chunk.toString("utf-8"));
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
this.handleLine(line);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
this.process.stderr.on("data", (chunk) => {
|
|
43
|
+
console.error(`[${name}] stderr: ${chunk.toString("utf-8").trimEnd()}`);
|
|
44
|
+
});
|
|
45
|
+
this.process.on("exit", (code) => {
|
|
46
|
+
console.log(`[${name}] Process exited (code=${code})`);
|
|
47
|
+
this.destroyed = true;
|
|
48
|
+
this.failPending(protocol_js_1.ErrorCode.PROCESS_EXITED, `code=${code}`);
|
|
49
|
+
});
|
|
50
|
+
this.process.on("error", (err) => {
|
|
51
|
+
console.error(`[${name}] Process error: ${err.message}`);
|
|
52
|
+
this.destroyed = true;
|
|
53
|
+
// Spawn failures (ENOENT, EACCES, …) emit 'error' and may emit
|
|
54
|
+
// 'exit' only later or not at all. Without failing pending here,
|
|
55
|
+
// any in-flight request blocks until its per-request timeout.
|
|
56
|
+
this.failPending(protocol_js_1.ErrorCode.PROCESS_NOT_RUNNING, err.message);
|
|
57
|
+
});
|
|
58
|
+
// EPIPE / write-after-close on the child's stdin shows up as an 'error'
|
|
59
|
+
// on the stream itself, distinct from the process error.
|
|
60
|
+
this.process.stdin?.on("error", (err) => {
|
|
61
|
+
console.error(`[${name}] stdin error: ${err.message}`);
|
|
62
|
+
this.destroyed = true;
|
|
63
|
+
this.failPending(protocol_js_1.ErrorCode.PROCESS_NOT_RUNNING, `stdin: ${err.message}`);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
failPending(code, detail) {
|
|
67
|
+
for (const [id, p] of this.pending) {
|
|
68
|
+
clearTimeout(p.timer);
|
|
69
|
+
p.resolve((0, protocol_js_1.jsonRpcError)(code, detail, id));
|
|
70
|
+
}
|
|
71
|
+
this.pending.clear();
|
|
72
|
+
}
|
|
73
|
+
handleLine(line) {
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(line);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return; // Not valid JSON, skip
|
|
80
|
+
}
|
|
81
|
+
// Response shape: id present, no method. (Server-initiated requests
|
|
82
|
+
// also have an id, but they carry a method too and belong on the
|
|
83
|
+
// notification path so the SSE reader can route them to the bridge.)
|
|
84
|
+
const isResponse = parsed.id !== undefined && typeof parsed.method !== "string";
|
|
85
|
+
if (isResponse) {
|
|
86
|
+
const p = this.pending.get(parsed.id);
|
|
87
|
+
if (p) {
|
|
88
|
+
// Matched delivery is real activity, so refresh lastActivity. We
|
|
89
|
+
// deliberately do NOT refresh it for queued notifications below —
|
|
90
|
+
// if the SSE reader is gone, an upstream that chatters
|
|
91
|
+
// notifications would otherwise keep this session alive forever
|
|
92
|
+
// AND grow the notifications queue. Streams with an active reader
|
|
93
|
+
// still bump lastActivity via drainNotifications().
|
|
94
|
+
this.lastActivity = Date.now();
|
|
95
|
+
clearTimeout(p.timer);
|
|
96
|
+
this.pending.delete(parsed.id);
|
|
97
|
+
p.resolve(line);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Orphan: a response that arrived after the request already timed
|
|
101
|
+
// out (and was answered with REQUEST_TIMEOUT) or carries an id we
|
|
102
|
+
// don't know. Dropping is the only correct action — without this
|
|
103
|
+
// it would be queued as a notification, evicting real progress/log
|
|
104
|
+
// notifications from the bounded ring buffer and adding spurious
|
|
105
|
+
// SSE traffic. Counted so the next drain can log a single line.
|
|
106
|
+
this.orphansDropped++;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Notification (no id) or server-initiated request (id + method) —
|
|
110
|
+
// both belong on the SSE drain path. Bounded ring buffer so a dead/
|
|
111
|
+
// slow SSE reader can't grow this without limit; drop the oldest
|
|
112
|
+
// entry (FIFO discipline) and account for the loss.
|
|
113
|
+
if (this.notifications.length >= constants_js_1.MAX_QUEUED_NOTIFICATIONS) {
|
|
114
|
+
this.notifications.shift();
|
|
115
|
+
this.notificationsDropped++;
|
|
116
|
+
}
|
|
117
|
+
this.notifications.push(line);
|
|
118
|
+
}
|
|
119
|
+
sendRequest(jsonRpcLine) {
|
|
120
|
+
if (this.destroyed || !this.process.stdin?.writable) {
|
|
121
|
+
return Promise.resolve((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PROCESS_NOT_RUNNING));
|
|
122
|
+
}
|
|
123
|
+
this.lastActivity = Date.now();
|
|
124
|
+
// Inspect the JSON-RPC shape to decide how to handle the body.
|
|
125
|
+
let parsed = {};
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(jsonRpcLine);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Not parseable — fall through and forward verbatim, no matching.
|
|
131
|
+
}
|
|
132
|
+
// Notification: no id. Response from client for a server-initiated
|
|
133
|
+
// request: has id but no method (and result/error). Both are
|
|
134
|
+
// fire-and-forget from the host's perspective.
|
|
135
|
+
const isNotification = parsed.id === undefined;
|
|
136
|
+
const isResponse = !isNotification && typeof parsed.method !== "string";
|
|
137
|
+
if (isNotification || isResponse) {
|
|
138
|
+
try {
|
|
139
|
+
this.process.stdin.write(jsonRpcLine + "\n");
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
// Best effort — no caller is waiting on a result here.
|
|
143
|
+
console.error(`[${this.name}] stdin write failed: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
return Promise.resolve("");
|
|
146
|
+
}
|
|
147
|
+
const id = parsed.id;
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
this.pending.delete(id);
|
|
151
|
+
resolve((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.REQUEST_TIMEOUT, undefined, id));
|
|
152
|
+
}, this.timeout);
|
|
153
|
+
// Register pending FIRST so a 'error' / stdin 'error' handler that
|
|
154
|
+
// fires synchronously during stdin.write() can find this entry via
|
|
155
|
+
// failPending() and resolve it. Writing first would leave a tiny
|
|
156
|
+
// window where the entry isn't yet registered when the listener
|
|
157
|
+
// drains this.pending, leading to a hung promise.
|
|
158
|
+
this.pending.set(id, { resolve, timer });
|
|
159
|
+
try {
|
|
160
|
+
this.process.stdin.write(jsonRpcLine + "\n");
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
// Synchronous EPIPE on a half-dead child. The async stdin 'error'
|
|
164
|
+
// listener may or may not fire for this — fail this entry now so
|
|
165
|
+
// the caller doesn't sit through the full request timeout.
|
|
166
|
+
if (this.pending.get(id)?.timer === timer) {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
this.pending.delete(id);
|
|
169
|
+
resolve((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PROCESS_NOT_RUNNING, err.message, id));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
drainNotifications() {
|
|
175
|
+
const n = this.notifications;
|
|
176
|
+
this.notifications = [];
|
|
177
|
+
// SSE listener actively reading — also a sign of life.
|
|
178
|
+
if (n.length > 0)
|
|
179
|
+
this.lastActivity = Date.now();
|
|
180
|
+
if (this.notificationsDropped > 0) {
|
|
181
|
+
console.error(`[${this.name}] dropped ${this.notificationsDropped} queued notification(s) (cap=${constants_js_1.MAX_QUEUED_NOTIFICATIONS}); SSE reader was behind`);
|
|
182
|
+
this.notificationsDropped = 0;
|
|
183
|
+
}
|
|
184
|
+
if (this.orphansDropped > 0) {
|
|
185
|
+
console.error(`[${this.name}] dropped ${this.orphansDropped} late/unmatched response(s); requests already timed out`);
|
|
186
|
+
this.orphansDropped = 0;
|
|
187
|
+
}
|
|
188
|
+
return n;
|
|
189
|
+
}
|
|
190
|
+
get serverName() {
|
|
191
|
+
return this.name;
|
|
192
|
+
}
|
|
193
|
+
get isAlive() {
|
|
194
|
+
return !this.destroyed;
|
|
195
|
+
}
|
|
196
|
+
destroy() {
|
|
197
|
+
if (this.destroyed)
|
|
198
|
+
return;
|
|
199
|
+
this.destroyed = true;
|
|
200
|
+
if (!this.process.killed)
|
|
201
|
+
this.process.kill();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
exports.McpSession = McpSession;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startTunnel = startTunnel;
|
|
4
|
+
const cloudflared_1 = require("cloudflared");
|
|
5
|
+
// Bound how long we wait for cloudflared to advertise the public URL.
|
|
6
|
+
// Anything slower than this is almost always a configuration / network /
|
|
7
|
+
// account issue we want to surface to the user instead of hanging silently.
|
|
8
|
+
const TUNNEL_STARTUP_TIMEOUT_MS = 30_000;
|
|
9
|
+
// Start a quick cloudflared tunnel and resolve once cloudflared advertises
|
|
10
|
+
// the public URL. Rejects with a descriptive error if cloudflared:
|
|
11
|
+
// - errors out before becoming ready (binary missing, network unreachable,
|
|
12
|
+
// account auth failure, etc.),
|
|
13
|
+
// - exits before advertising a URL,
|
|
14
|
+
// - or doesn't surface a URL within TUNNEL_STARTUP_TIMEOUT_MS.
|
|
15
|
+
//
|
|
16
|
+
// `onUnexpectedExit` fires only AFTER the URL was advertised — i.e., a
|
|
17
|
+
// runtime failure once the tunnel was healthy. Pre-ready failures already
|
|
18
|
+
// reject the start() promise, so the caller learns about those
|
|
19
|
+
// synchronously and can decide whether to keep the host running locally or
|
|
20
|
+
// shut it down.
|
|
21
|
+
function startTunnel(port, onUnexpectedExit) {
|
|
22
|
+
const tunnel = cloudflared_1.Tunnel.quick(`http://localhost:${port}`);
|
|
23
|
+
// Capture the most recent cloudflared error so a subsequent exit/timeout
|
|
24
|
+
// can include the real cause in the rejection instead of "did not produce
|
|
25
|
+
// a URL".
|
|
26
|
+
let lastErrorMessage = null;
|
|
27
|
+
tunnel.on("error", (err) => {
|
|
28
|
+
lastErrorMessage = err.message;
|
|
29
|
+
process.stderr.write(`Tunnel error: ${err.message}\n`);
|
|
30
|
+
});
|
|
31
|
+
return new Promise((resolveP, rejectP) => {
|
|
32
|
+
let settled = false;
|
|
33
|
+
let urlReady = false;
|
|
34
|
+
const finishStartup = (fn) => {
|
|
35
|
+
if (settled)
|
|
36
|
+
return;
|
|
37
|
+
settled = true;
|
|
38
|
+
clearTimeout(startupTimer);
|
|
39
|
+
fn();
|
|
40
|
+
};
|
|
41
|
+
const startupTimer = setTimeout(() => {
|
|
42
|
+
finishStartup(() => {
|
|
43
|
+
try {
|
|
44
|
+
tunnel.stop();
|
|
45
|
+
}
|
|
46
|
+
catch { /* already gone */ }
|
|
47
|
+
const cause = lastErrorMessage ? ` (last error: ${lastErrorMessage})` : "";
|
|
48
|
+
rejectP(new Error(`Cloudflare tunnel did not produce a URL within ${TUNNEL_STARTUP_TIMEOUT_MS / 1000}s${cause}`));
|
|
49
|
+
});
|
|
50
|
+
}, TUNNEL_STARTUP_TIMEOUT_MS);
|
|
51
|
+
tunnel.once("url", (url) => {
|
|
52
|
+
urlReady = true;
|
|
53
|
+
console.log(`Tunnel URL: ${url}`);
|
|
54
|
+
finishStartup(() => {
|
|
55
|
+
resolveP({
|
|
56
|
+
url,
|
|
57
|
+
stop: () => { try {
|
|
58
|
+
tunnel.stop();
|
|
59
|
+
}
|
|
60
|
+
catch { /* already stopped */ } },
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
tunnel.on("exit", (code, signal) => {
|
|
65
|
+
const detail = code !== null
|
|
66
|
+
? `code ${code}`
|
|
67
|
+
: signal !== null ? `signal ${signal}` : "unknown reason";
|
|
68
|
+
const errBit = lastErrorMessage ? ` (${lastErrorMessage})` : "";
|
|
69
|
+
const reason = `cloudflared exited (${detail})${errBit}`;
|
|
70
|
+
if (!urlReady) {
|
|
71
|
+
finishStartup(() => rejectP(new Error(reason)));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// URL was already advertised — this is a runtime failure. Surface
|
|
75
|
+
// through the caller's hook so cli.ts (or whoever owns the lifecycle)
|
|
76
|
+
// can log it and decide what to do.
|
|
77
|
+
process.stderr.write(`Tunnel ${reason}\n`);
|
|
78
|
+
if (onUnexpectedExit)
|
|
79
|
+
onUnexpectedExit(reason);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
package/dist/host.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const cli_js_1 = require("./host/cli.js");
|
|
5
|
+
(0, cli_js_1.main)().catch((err) => {
|
|
6
|
+
console.error(`Host agent failed to start: ${err.message}`);
|
|
7
|
+
process.exit(1);
|
|
8
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Prompt, Tool } from "./types.js";
|
|
2
|
+
export declare const TOOL_SEPARATOR = "__";
|
|
3
|
+
export declare const TUNNEL_STARTUP_TIMEOUT_MS = 30000;
|
|
4
|
+
export declare const PAIRING_WINDOW_MS: number;
|
|
5
|
+
export declare const UPSTREAM_REQUEST_TIMEOUT_MS = 120000;
|
|
6
|
+
export declare const DISCOVERY_FETCH_TIMEOUT_MS = 15000;
|
|
7
|
+
export declare const TOOL_FORWARD_TIMEOUT_MS: number;
|
|
8
|
+
export declare const SESSION_DELETE_TIMEOUT_MS = 5000;
|
|
9
|
+
export declare const SSE_BACKOFF_INITIAL_MS = 500;
|
|
10
|
+
export declare const SSE_BACKOFF_MAX_MS = 10000;
|
|
11
|
+
export declare const SSE_CONNECT_TIMEOUT_MS = 15000;
|
|
12
|
+
export declare const CONFIGURE_TOOL: Tool;
|
|
13
|
+
export declare const CONFIGURE_PROMPT: Prompt;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CONFIGURE_PROMPT = exports.CONFIGURE_TOOL = exports.SSE_CONNECT_TIMEOUT_MS = exports.SSE_BACKOFF_MAX_MS = exports.SSE_BACKOFF_INITIAL_MS = exports.SESSION_DELETE_TIMEOUT_MS = exports.TOOL_FORWARD_TIMEOUT_MS = exports.DISCOVERY_FETCH_TIMEOUT_MS = exports.UPSTREAM_REQUEST_TIMEOUT_MS = exports.PAIRING_WINDOW_MS = exports.TUNNEL_STARTUP_TIMEOUT_MS = exports.TOOL_SEPARATOR = void 0;
|
|
4
|
+
const protocol_js_1 = require("../../shared/protocol.js");
|
|
5
|
+
exports.TOOL_SEPARATOR = protocol_js_1.TOOL_NAME_SEPARATOR;
|
|
6
|
+
// Pairing-tunnel + bridging budgets.
|
|
7
|
+
exports.TUNNEL_STARTUP_TIMEOUT_MS = 30_000; // bring up cloudflared
|
|
8
|
+
exports.PAIRING_WINDOW_MS = 10 * 60 * 1000; // hard expiry per pairing
|
|
9
|
+
exports.UPSTREAM_REQUEST_TIMEOUT_MS = 120_000; // server→client bridge
|
|
10
|
+
// Per-fetch budgets. Discovery + pairing-forward are short — anything that
|
|
11
|
+
// can't answer the MCP handshake in this many milliseconds is broken enough
|
|
12
|
+
// to surface to the user. Tool calls / prompt gets / resource reads ride a
|
|
13
|
+
// much longer budget because they are user-bound (long shell commands,
|
|
14
|
+
// large filesystem reads, etc.).
|
|
15
|
+
exports.DISCOVERY_FETCH_TIMEOUT_MS = 15_000;
|
|
16
|
+
exports.TOOL_FORWARD_TIMEOUT_MS = 5 * 60 * 1000;
|
|
17
|
+
exports.SESSION_DELETE_TIMEOUT_MS = 5_000;
|
|
18
|
+
exports.SSE_BACKOFF_INITIAL_MS = 500;
|
|
19
|
+
exports.SSE_BACKOFF_MAX_MS = 10_000;
|
|
20
|
+
// Bound the connect+headers phase only. A blackholed tunnel would otherwise
|
|
21
|
+
// leave fetch() waiting on the OS connect timeout (Linux ~127s) before the
|
|
22
|
+
// loop could fall through to backoff, freezing list_changed and server-
|
|
23
|
+
// initiated requests for that session. The streaming body is NOT bounded
|
|
24
|
+
// by this timeout — once headers arrive, the read loop runs under the
|
|
25
|
+
// lifecycle signal alone.
|
|
26
|
+
exports.SSE_CONNECT_TIMEOUT_MS = 15_000;
|
|
27
|
+
// Local tool: always advertised so a client can re-pair without a process
|
|
28
|
+
// restart, even if discovery returned zero upstream tools.
|
|
29
|
+
exports.CONFIGURE_TOOL = {
|
|
30
|
+
name: "configure",
|
|
31
|
+
description: "Set up or reconfigure the MCP proxy connection. Returns the setup URL.",
|
|
32
|
+
inputSchema: { type: "object", properties: {} },
|
|
33
|
+
};
|
|
34
|
+
// Local prompt counterpart. Surfaced even when no upstream prompts exist so
|
|
35
|
+
// an agent can always pull up the setup URL through prompts/get.
|
|
36
|
+
exports.CONFIGURE_PROMPT = {
|
|
37
|
+
name: "configure",
|
|
38
|
+
description: "Set up or reconfigure the MCP proxy connection.",
|
|
39
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function timeoutSignal(ms: number, parent?: AbortSignal): AbortSignal;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.timeoutSignal = timeoutSignal;
|
|
4
|
+
// Combine a per-request timeout with an optional caller signal (e.g., a
|
|
5
|
+
// session-scoped AbortController). AbortSignal.any() is the floor (Node
|
|
6
|
+
// 20.3+); package.json's engines.node enforces it at install time so we
|
|
7
|
+
// don't need a polyfill or a runtime guard here.
|
|
8
|
+
//
|
|
9
|
+
// We use this for every upstream fetch so a wedged tunnel can't pin the
|
|
10
|
+
// proxy indefinitely. Discovery uses DISCOVERY_FETCH_TIMEOUT_MS (15s),
|
|
11
|
+
// runtime tool forwards use TOOL_FORWARD_TIMEOUT_MS (5min); see constants.ts.
|
|
12
|
+
function timeoutSignal(ms, parent) {
|
|
13
|
+
const t = AbortSignal.timeout(ms);
|
|
14
|
+
return parent ? AbortSignal.any([parent, t]) : t;
|
|
15
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ResourceRouter } from "../routing/router.js";
|
|
2
|
+
import type { HostConfig, HostState, PairingConfig, ToolRoute } from "./types.js";
|
|
3
|
+
export declare class ProxyState {
|
|
4
|
+
config: PairingConfig | null;
|
|
5
|
+
hosts: Map<string, HostState>;
|
|
6
|
+
toolRoute: Map<string, ToolRoute>;
|
|
7
|
+
promptRoute: Map<string, ToolRoute>;
|
|
8
|
+
resources: ResourceRouter;
|
|
9
|
+
templateRoutes: Array<{
|
|
10
|
+
uriTemplate: string;
|
|
11
|
+
route: ToolRoute;
|
|
12
|
+
}>;
|
|
13
|
+
clientCapabilities: Record<string, unknown>;
|
|
14
|
+
clientInfo: {
|
|
15
|
+
name: string;
|
|
16
|
+
version: string;
|
|
17
|
+
};
|
|
18
|
+
discoveryInflight: Promise<void> | null;
|
|
19
|
+
configGeneration: number;
|
|
20
|
+
inflight: Map<string | number, ToolRoute>;
|
|
21
|
+
progressTokens: Map<string | number, ToolRoute>;
|
|
22
|
+
hostHeaders(host: HostConfig): Record<string, string>;
|
|
23
|
+
installConfig(config: PairingConfig, hosts: HostConfig[]): void;
|
|
24
|
+
resetAfterClose(): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ProxyState = void 0;
|
|
4
|
+
const protocol_js_1 = require("../../shared/protocol.js");
|
|
5
|
+
const router_js_1 = require("../routing/router.js");
|
|
6
|
+
// Mutable proxy-wide state. Holds the paired config, host map, route maps,
|
|
7
|
+
// and the captured client identity. All long-running components
|
|
8
|
+
// (DiscoveryRunner, Forwarder, RequestHandlers, PairingController) read
|
|
9
|
+
// and write through this single object so the proxy has exactly one source
|
|
10
|
+
// of truth for "what is paired right now". Mutating helpers
|
|
11
|
+
// (`installConfig`, `resetAfterClose`) keep the swap atomic — replacing
|
|
12
|
+
// every Map at once so an in-flight discovery from a prior pairing can
|
|
13
|
+
// detect supersession by comparing identity / generation rather than
|
|
14
|
+
// racing in-place mutations.
|
|
15
|
+
class ProxyState {
|
|
16
|
+
config = null;
|
|
17
|
+
hosts = new Map();
|
|
18
|
+
toolRoute = new Map();
|
|
19
|
+
promptRoute = new Map();
|
|
20
|
+
resources = new router_js_1.ResourceRouter();
|
|
21
|
+
templateRoutes = [];
|
|
22
|
+
// Client-declared capabilities + info, captured at initialize and
|
|
23
|
+
// forwarded upstream when we open each MCP session. This is what makes
|
|
24
|
+
// sampling / elicitation / roots actually work end-to-end: the upstream
|
|
25
|
+
// server sees the real client's feature flags, not an empty object.
|
|
26
|
+
// Pairing-time discovery uses these too, so the setup UI sees the same
|
|
27
|
+
// capability set the runtime path will see.
|
|
28
|
+
clientCapabilities = {};
|
|
29
|
+
clientInfo = { name: protocol_js_1.PACKAGE_NAME, version: protocol_js_1.PACKAGE_VERSION };
|
|
30
|
+
// Single-flight guard: every caller of discoverServers awaits the same
|
|
31
|
+
// run. Two passes racing would double-spawn upstream sessions and let
|
|
32
|
+
// their session-id rotations clobber each other's toolRoute writes.
|
|
33
|
+
discoveryInflight = null;
|
|
34
|
+
// Supersession token for config/hosts. handleComplete is the sole writer
|
|
35
|
+
// and bumps this on every swap; long-running readers (discovery, etc.)
|
|
36
|
+
// snapshot it at the start and refuse to write back to shared state if
|
|
37
|
+
// the value has moved on.
|
|
38
|
+
configGeneration = 0;
|
|
39
|
+
// requestId → route for in-flight agent→server requests. Used to route
|
|
40
|
+
// notifications/cancelled to the originating session.
|
|
41
|
+
inflight = new Map();
|
|
42
|
+
// progressToken → route. Populated when the agent issues a request whose
|
|
43
|
+
// params._meta carries a progressToken; consulted on
|
|
44
|
+
// notifications/progress so the proxy forwards the update to the right
|
|
45
|
+
// upstream session instead of dropping or broadcasting it.
|
|
46
|
+
progressTokens = new Map();
|
|
47
|
+
hostHeaders(host) {
|
|
48
|
+
return {
|
|
49
|
+
"Content-Type": "application/json",
|
|
50
|
+
Accept: "application/json, text/event-stream",
|
|
51
|
+
Authorization: `Bearer ${host.authToken}`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Atomic swap on re-pair. Bumps the generation token, then replaces the
|
|
55
|
+
// host map with brand-new Maps so any in-flight discovery from a prior
|
|
56
|
+
// pairing can detect it has been superseded by comparing identity /
|
|
57
|
+
// token rather than racing in-place mutations. discoveryInflight is
|
|
58
|
+
// nulled too, otherwise the next discoverServers() call would reuse the
|
|
59
|
+
// prior pairing's promise and skip its own run.
|
|
60
|
+
installConfig(config, hosts) {
|
|
61
|
+
this.configGeneration++;
|
|
62
|
+
this.config = config;
|
|
63
|
+
const newHosts = new Map();
|
|
64
|
+
for (const h of hosts) {
|
|
65
|
+
newHosts.set(h.id, {
|
|
66
|
+
config: h,
|
|
67
|
+
servers: new Map(),
|
|
68
|
+
sseControllers: new Map(),
|
|
69
|
+
listed: false,
|
|
70
|
+
pendingServers: new Set(),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
this.hosts = newHosts;
|
|
74
|
+
this.toolRoute = new Map();
|
|
75
|
+
this.promptRoute = new Map();
|
|
76
|
+
this.resources.clear();
|
|
77
|
+
this.templateRoutes = [];
|
|
78
|
+
this.discoveryInflight = null;
|
|
79
|
+
}
|
|
80
|
+
// Drop every host/route after sessions have been closed. Called from
|
|
81
|
+
// PairingController.closeAllSessions on its way out of an old pairing.
|
|
82
|
+
resetAfterClose() {
|
|
83
|
+
this.hosts = new Map();
|
|
84
|
+
this.toolRoute = new Map();
|
|
85
|
+
this.promptRoute = new Map();
|
|
86
|
+
this.resources.clear();
|
|
87
|
+
this.templateRoutes = [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
exports.ProxyState = ProxyState;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface Tool {
|
|
2
|
+
name: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
inputSchema?: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface Prompt {
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
arguments?: unknown;
|
|
10
|
+
}
|
|
11
|
+
export interface Resource {
|
|
12
|
+
uri: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
mimeType?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ResourceTemplate {
|
|
18
|
+
uriTemplate: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
mimeType?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface HostConfig {
|
|
24
|
+
id: string;
|
|
25
|
+
tunnelUrl: string;
|
|
26
|
+
authToken: string;
|
|
27
|
+
label?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface PairingConfig {
|
|
30
|
+
hosts: HostConfig[];
|
|
31
|
+
selectedServers?: string[];
|
|
32
|
+
selectedTools?: string[];
|
|
33
|
+
sealed: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface ServerState {
|
|
36
|
+
sessionId?: string;
|
|
37
|
+
tools: Tool[];
|
|
38
|
+
prompts: Prompt[];
|
|
39
|
+
resources: Resource[];
|
|
40
|
+
resourceTemplates: ResourceTemplate[];
|
|
41
|
+
pendingPrompts: boolean;
|
|
42
|
+
pendingResources: boolean;
|
|
43
|
+
pendingResourceTemplates: boolean;
|
|
44
|
+
subscriptions: Set<string>;
|
|
45
|
+
}
|
|
46
|
+
export interface HostState {
|
|
47
|
+
config: HostConfig;
|
|
48
|
+
servers: Map<string, ServerState>;
|
|
49
|
+
sseControllers: Map<string, AbortController>;
|
|
50
|
+
listed: boolean;
|
|
51
|
+
pendingServers: Set<string>;
|
|
52
|
+
}
|
|
53
|
+
export interface ToolRoute {
|
|
54
|
+
hostId: string;
|
|
55
|
+
serverName: string;
|
|
56
|
+
originalName: string;
|
|
57
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Type definitions shared across proxy submodules. Kept in one place so the
|
|
3
|
+
// route map / pairing config / server state contracts are visible without
|
|
4
|
+
// chasing imports through every file.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Prompt, Resource, ResourceTemplate, Tool } from "../core/types.js";
|
|
2
|
+
interface InitResponse {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
status: number;
|
|
5
|
+
sessionId?: string;
|
|
6
|
+
body: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function initializeServer(targetUrl: string, baseHeaders: Record<string, string>, name: string, clientCapabilities: Record<string, unknown>, clientInfo: {
|
|
9
|
+
name: string;
|
|
10
|
+
version: string;
|
|
11
|
+
}): Promise<InitResponse>;
|
|
12
|
+
export declare function sendInitialized(targetUrl: string, sessionHeaders: Record<string, string>): Promise<void>;
|
|
13
|
+
export declare function fetchTools(targetUrl: string, headers: Record<string, string>, name: string): Promise<Tool[]>;
|
|
14
|
+
export declare function fetchPromptsStrict(targetUrl: string, headers: Record<string, string>, name: string): Promise<Prompt[]>;
|
|
15
|
+
export declare function fetchResourcesStrict(targetUrl: string, headers: Record<string, string>, name: string): Promise<Resource[]>;
|
|
16
|
+
export declare function fetchResourceTemplatesStrict(targetUrl: string, headers: Record<string, string>, name: string): Promise<ResourceTemplate[]>;
|
|
17
|
+
export interface ServerDiscoveryResult {
|
|
18
|
+
sessionId: string | undefined;
|
|
19
|
+
tools: Tool[];
|
|
20
|
+
prompts: Prompt[];
|
|
21
|
+
resources: Resource[];
|
|
22
|
+
resourceTemplates: ResourceTemplate[];
|
|
23
|
+
pendingPrompts: boolean;
|
|
24
|
+
pendingResources: boolean;
|
|
25
|
+
pendingResourceTemplates: boolean;
|
|
26
|
+
capErrors: {
|
|
27
|
+
prompts?: string;
|
|
28
|
+
resources?: string;
|
|
29
|
+
resourceTemplates?: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export declare class DiscoveryError extends Error {
|
|
33
|
+
readonly sessionId: string | undefined;
|
|
34
|
+
constructor(message: string, sessionId: string | undefined);
|
|
35
|
+
}
|
|
36
|
+
export declare function discoverServerCapabilities(targetUrl: string, baseHeaders: Record<string, string>, name: string, clientCapabilities: Record<string, unknown>, clientInfo: {
|
|
37
|
+
name: string;
|
|
38
|
+
version: string;
|
|
39
|
+
}, log?: (line: string) => void): Promise<ServerDiscoveryResult>;
|
|
40
|
+
export declare function deleteSession(targetUrl: string, headers: Record<string, string>): Promise<void>;
|
|
41
|
+
export declare function listHostServers(hostUrl: string, headers: Record<string, string>, log?: (line: string) => void): Promise<string[]>;
|
|
42
|
+
export {};
|