@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.
Files changed (69) hide show
  1. package/README.md +62 -17
  2. package/dist/host/agent.d.ts +22 -0
  3. package/dist/host/agent.js +314 -0
  4. package/dist/host/cli.d.ts +1 -0
  5. package/dist/host/cli.js +83 -0
  6. package/dist/host/constants.d.ts +4 -0
  7. package/dist/host/constants.js +16 -0
  8. package/dist/host/session.d.ts +21 -0
  9. package/dist/host/session.js +204 -0
  10. package/dist/host/tunnel.d.ts +5 -0
  11. package/dist/host/tunnel.js +82 -0
  12. package/dist/host.js +8 -0
  13. package/dist/proxy/core/constants.d.ts +13 -0
  14. package/dist/proxy/core/constants.js +39 -0
  15. package/dist/proxy/core/fetch-timeout.d.ts +1 -0
  16. package/dist/proxy/core/fetch-timeout.js +15 -0
  17. package/dist/proxy/core/state.d.ts +25 -0
  18. package/dist/proxy/core/state.js +90 -0
  19. package/dist/proxy/core/types.d.ts +57 -0
  20. package/dist/proxy/core/types.js +5 -0
  21. package/dist/proxy/discovery/client.d.ts +42 -0
  22. package/dist/proxy/discovery/client.js +283 -0
  23. package/dist/proxy/discovery/runner.d.ts +21 -0
  24. package/dist/proxy/discovery/runner.js +319 -0
  25. package/dist/proxy/pairing/config.d.ts +9 -0
  26. package/dist/proxy/pairing/config.js +130 -0
  27. package/dist/proxy/pairing/controller.d.ts +19 -0
  28. package/dist/proxy/pairing/controller.js +327 -0
  29. package/dist/proxy/pairing/http.d.ts +70 -0
  30. package/dist/proxy/pairing/http.js +155 -0
  31. package/dist/proxy/pairing/static-assets.d.ts +4 -0
  32. package/dist/proxy/pairing/static-assets.js +13 -0
  33. package/dist/proxy/pairing/tunnel.d.ts +13 -0
  34. package/dist/proxy/pairing/tunnel.js +130 -0
  35. package/dist/proxy/pairing/validation.d.ts +2 -0
  36. package/dist/proxy/pairing/validation.js +62 -0
  37. package/dist/proxy/routing/filtering.d.ts +13 -0
  38. package/dist/proxy/routing/filtering.js +116 -0
  39. package/dist/proxy/routing/router.d.ts +17 -0
  40. package/dist/proxy/routing/router.js +74 -0
  41. package/dist/proxy/routing/uri.d.ts +7 -0
  42. package/dist/proxy/routing/uri.js +39 -0
  43. package/dist/proxy/runtime/forwarder.d.ts +15 -0
  44. package/dist/proxy/runtime/forwarder.js +265 -0
  45. package/dist/proxy/runtime/handlers.d.ts +48 -0
  46. package/dist/proxy/runtime/handlers.js +329 -0
  47. package/dist/proxy/runtime/sse.d.ts +19 -0
  48. package/dist/proxy/runtime/sse.js +169 -0
  49. package/dist/proxy/runtime/upstream-bridge.d.ts +27 -0
  50. package/dist/proxy/runtime/upstream-bridge.js +133 -0
  51. package/dist/proxy/server.d.ts +15 -0
  52. package/dist/proxy/server.js +167 -0
  53. package/dist/proxy.js +5 -0
  54. package/{mcp/dist → dist}/shared/protocol.d.ts +15 -3
  55. package/dist/shared/protocol.js +183 -0
  56. package/dist/wrapper.d.ts +2 -0
  57. package/dist/wrapper.js +72 -0
  58. package/package.json +15 -7
  59. package/static/setup.css +233 -0
  60. package/static/setup.html +57 -0
  61. package/static/setup.js +711 -0
  62. package/static/style.css +208 -0
  63. package/mcp/dist/host.js +0 -307
  64. package/mcp/dist/proxy.js +0 -377
  65. package/mcp/dist/shared/generated.d.ts +0 -2
  66. package/mcp/dist/shared/generated.js +0 -5
  67. package/mcp/dist/shared/protocol.js +0 -79
  68. /package/{mcp/dist → dist}/host.d.ts +0 -0
  69. /package/{mcp/dist → dist}/proxy.d.ts +0 -0
package/README.md CHANGED
@@ -71,13 +71,24 @@ Add the proxy as a stdio MCP server. The client launches it automatically.
71
71
 
72
72
  ### 3. Pair
73
73
 
74
- When the MCP client spawns the proxy, the proxy prints a setup URL to stderr:
74
+ The proxy starts idle. Ask your MCP client to call the `configure` tool (or
75
+ prompt) — the proxy then spins up an ephemeral pairing tunnel that serves
76
+ both the setup page and the pairing API on the same origin, and prints a
77
+ setup URL to stderr:
75
78
 
76
79
  ```
77
- Configure at: https://mcp-proxy.pages.dev/setup.html#code=...&key=...
80
+ Configure at: https://abc-xyz.trycloudflare.com/#token=...
78
81
  ```
79
82
 
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.
83
+ Open the URL in a browser. Add one or more host agents each row takes a
84
+ host id (a slug you choose), tunnel URL, and auth token — discover servers,
85
+ and select tools. The proxy applies the config and tears down the pairing
86
+ tunnel automatically.
87
+
88
+ A single proxy can fan out to multiple hosts at once. Tools are namespaced
89
+ as `<hostId>__<serverName>__<toolName>` so the same server name can appear
90
+ on more than one host without collision. (The host agent itself stays
91
+ single-proxy, in line with MCP's one-server-one-client model.)
81
92
 
82
93
  ## Architecture
83
94
 
@@ -85,21 +96,36 @@ Open the URL in a browser. Enter the tunnel URL and auth token from step 1, disc
85
96
 
86
97
  | Component | Role | Runs on |
87
98
  |-----------|------|---------|
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 |
99
+ | **Host Agent** (`host`) | HTTP-to-stdio bridge. Spawns MCP servers, manages sessions, serves MCP Streamable HTTP over a long-lived Cloudflare tunnel. | Machine with resources |
100
+ | **Proxy Server** (`proxy`) | Stdio MCP server. Idle at startup; on `configure` it spins up an ephemeral pairing tunnel via the bundled wrapper, serves the setup page on that same tunnel, accepts the pairing handshake, then talks to the host's tunnel for ongoing MCP traffic. | Machine with MCP client |
91
101
 
92
- ### Pairing flow
102
+ ### Pairing flow (lazy-start, single-origin)
93
103
 
94
104
  ```
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
105
+ 1. MCP client spawns the proxy (stdio). Proxy is idle — no tunnel, no polling.
106
+ 2. Agent calls the `configure` tool. Proxy spawns a Node wrapper that owns
107
+ a `cloudflared` quick tunnel pointing at a local pairing HTTP server.
108
+ That HTTP server serves both the setup page (GET /) and the pairing API
109
+ (POST /pair/forward, POST /pair/complete) on the same origin.
110
+ 3. Wrapper prints the tunnel URL. Proxy mints a bearer token and emits a
111
+ setup URL — `<tunnel>/#token=<token>`. Token rides in the URL fragment
112
+ so it never appears in server access logs or Referer headers.
113
+ 4. User opens the setup URL. The page is served by the proxy itself, so
114
+ browser fetches to the pairing API are same-origin — no CORS dance.
115
+ Pairing endpoints are gated by the bearer token.
116
+ 5. Through the pairing API, the page discovers servers and tools on each
117
+ configured host's MCP tunnel, then submits the final configuration
118
+ (a list of hosts plus the selected tools).
119
+ 6. Proxy applies the config, signals the wrapper to tear down `cloudflared`,
120
+ and shuts the pairing HTTP server. From here on the proxy talks only to
121
+ the host's long-lived MCP tunnel — no public infrastructure, no polling.
101
122
  ```
102
123
 
124
+ The wrapper guarantees `cloudflared` cannot outlive the proxy. When the
125
+ proxy exits (or the wrapper sees stdin EOF), the wrapper kills the
126
+ `cloudflared` child immediately. Detection latency is 0ms on
127
+ Linux, macOS, and Windows.
128
+
103
129
  ### Protocol
104
130
 
105
131
  - **Client <-> Proxy**: stdio (JSON-RPC, newline-delimited)
@@ -151,12 +177,31 @@ host [options]
151
177
  **Proxy server:**
152
178
 
153
179
  ```
154
- proxy [options]
155
-
156
- --pages-url <url> Config page URL (default: https://mcp-proxy.pages.dev)
180
+ proxy
157
181
  ```
158
182
 
159
- Also reads `MCP_PROXY_PAGES_URL` environment variable.
183
+ The proxy takes no flags. The setup page is bundled with the npm package
184
+ and served by the proxy itself on the ephemeral pairing tunnel — there's
185
+ no external infrastructure to point at and no env vars to configure.
186
+
187
+ The pairing handshake runs entirely between the browser and the proxy's
188
+ ephemeral pairing tunnel, gated by a bearer token from the URL fragment.
189
+
190
+ Server names exposed by the host agent — and host ids you assign during
191
+ pairing — must match `[A-Za-z0-9._-]+` so they stay safe inside URLs and
192
+ the proxy's tool-name routing. Names that violate the policy are rejected
193
+ at host startup or pairing time.
194
+
195
+ ### Server-initiated requests
196
+
197
+ The proxy fully bridges server→client requests (sampling, elicitation,
198
+ roots/list, ping, …). When an upstream MCP server sends a request over its
199
+ SSE notification channel, the proxy remaps the request id, forwards it to
200
+ the MCP client, and routes the client's response back to the originating
201
+ host session with the original id restored. The real client's
202
+ `capabilities` are forwarded to each upstream server during initialize so
203
+ servers see the actual feature support rather than an empty capabilities
204
+ object.
160
205
 
161
206
  ## Error codes
162
207
 
@@ -0,0 +1,22 @@
1
+ export declare class HostAgent {
2
+ private config;
3
+ private sessions;
4
+ private timeout;
5
+ private authToken;
6
+ private gcTimer;
7
+ private server;
8
+ private boundHost;
9
+ private boundPort;
10
+ constructor(configPath: string, timeout: number, overrides?: {
11
+ host?: string;
12
+ port?: number;
13
+ });
14
+ get port(): number;
15
+ start(): Promise<void>;
16
+ shutdown(): void;
17
+ private sweepIdleSessions;
18
+ private handleRequest;
19
+ private authorized;
20
+ private handleMcpPost;
21
+ private handleSse;
22
+ }
@@ -0,0 +1,314 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HostAgent = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const node_fs_1 = require("node:fs");
6
+ const protocol_js_1 = require("../shared/protocol.js");
7
+ const constants_js_1 = require("./constants.js");
8
+ const session_js_1 = require("./session.js");
9
+ function sendSessionMismatchError(res, session, serverName) {
10
+ res.writeHead(400, { "Content-Type": "application/json" });
11
+ res.end(JSON.stringify({ error: `Session belongs to server '${session.serverName}', not '${serverName}'` }));
12
+ }
13
+ // HTTP-to-stdio bridge. Owns the session map, handles the auth check, and
14
+ // dispatches by method:
15
+ // GET / — list available servers (proxy discovery)
16
+ // POST /servers/:name — JSON-RPC request (initialize spawns a session;
17
+ // anything else against an unknown id is rejected
18
+ // with 404 + JSON error so the proxy can re-init)
19
+ // GET /servers/:name — SSE stream for that session's notifications
20
+ // DELETE /servers/:name — explicit session close
21
+ class HostAgent {
22
+ config;
23
+ sessions = new Map();
24
+ timeout;
25
+ authToken;
26
+ gcTimer = null;
27
+ server = null;
28
+ boundHost;
29
+ boundPort;
30
+ constructor(configPath, timeout, overrides) {
31
+ const raw = (0, node_fs_1.readFileSync)(configPath, "utf-8");
32
+ this.config = JSON.parse(raw);
33
+ this.timeout = timeout;
34
+ this.authToken = (0, node_crypto_1.randomBytes)(32).toString("base64url"); // 256-bit token
35
+ this.boundHost = overrides?.host ?? this.config.host ?? protocol_js_1.DEFAULT_HOST;
36
+ // port 0 = let the OS pick. Resolved to the real bound port in start().
37
+ this.boundPort = overrides?.port ?? this.config.port ?? protocol_js_1.DEFAULT_PORT;
38
+ // Reject server names that the proxy/page would silently drop later.
39
+ // Authoritative at config-load time so misnamed servers surface as a
40
+ // startup error instead of disappearing during discovery.
41
+ const invalid = [];
42
+ for (const name of Object.keys(this.config.servers)) {
43
+ const reason = (0, protocol_js_1.validateServerName)(name);
44
+ if (reason)
45
+ invalid.push(` - "${name}": ${reason}`);
46
+ }
47
+ if (invalid.length > 0) {
48
+ throw new Error(`Invalid server name(s) in ${configPath}:\n${invalid.join("\n")}\n` +
49
+ `Rename the entries in config.json so they match the policy.`);
50
+ }
51
+ }
52
+ get port() {
53
+ return this.boundPort;
54
+ }
55
+ // Resolves once the listener is bound. Tunnel mode passes port 0 and
56
+ // needs the real port back before it can start cloudflared, so callers
57
+ // must await this rather than racing the synchronous return.
58
+ //
59
+ // On a bind failure (EADDRINUSE, EACCES, …) we tear down everything we
60
+ // installed before rejecting: the GC interval, the listener reference,
61
+ // and any half-open server. Without this cleanup, a caller that catches
62
+ // the rejection and discards the agent leaks a running interval and a
63
+ // dangling server reference until process exit — invisible to the CLI
64
+ // (which just process.exits) but a real leak for library/test usage.
65
+ start() {
66
+ return new Promise((resolveP, rejectP) => {
67
+ const srv = (0, protocol_js_1.createServer)((req, res) => this.handleRequest(req, res));
68
+ this.server = srv;
69
+ this.gcTimer = setInterval(() => this.sweepIdleSessions(), constants_js_1.SESSION_GC_INTERVAL_MS);
70
+ const onError = (err) => {
71
+ if (this.gcTimer) {
72
+ clearInterval(this.gcTimer);
73
+ this.gcTimer = null;
74
+ }
75
+ this.server = null;
76
+ try {
77
+ srv.close();
78
+ }
79
+ catch { /* never bound */ }
80
+ rejectP(err);
81
+ };
82
+ srv.once("error", onError);
83
+ srv.listen(this.boundPort, this.boundHost, () => {
84
+ const addr = srv.address();
85
+ if (addr && typeof addr === "object")
86
+ this.boundPort = addr.port;
87
+ console.log(`MCP Host Agent listening on http://${this.boundHost}:${this.boundPort}`);
88
+ console.log(`Available servers: ${Object.keys(this.config.servers).join(", ")}`);
89
+ console.error(`Auth token: ${this.authToken}`);
90
+ srv.off("error", onError);
91
+ resolveP();
92
+ });
93
+ });
94
+ }
95
+ // Stop the GC timer, tear down every active session, and release the HTTP
96
+ // listener. Safe to call more than once. The listener close is what
97
+ // matters for library users — the CLI path immediately process.exits, but
98
+ // an embedder that re-creates the agent (tests, hot-reload, etc.) would
99
+ // otherwise leak the bound port. closeAllConnections() is required because
100
+ // the SSE handler keeps long-lived responses open; close() alone would
101
+ // wait for them to drain naturally and never resolve.
102
+ shutdown() {
103
+ if (this.gcTimer) {
104
+ clearInterval(this.gcTimer);
105
+ this.gcTimer = null;
106
+ }
107
+ for (const [id, session] of this.sessions) {
108
+ session.destroy();
109
+ this.sessions.delete(id);
110
+ }
111
+ if (this.server) {
112
+ const srv = this.server;
113
+ this.server = null;
114
+ srv.closeAllConnections();
115
+ srv.close();
116
+ }
117
+ }
118
+ sweepIdleSessions() {
119
+ const now = Date.now();
120
+ for (const [id, session] of this.sessions) {
121
+ if (!session.isAlive) {
122
+ this.sessions.delete(id);
123
+ continue;
124
+ }
125
+ if (now - session.lastActivity > constants_js_1.SESSION_IDLE_TIMEOUT_MS) {
126
+ console.log(`[${session.serverName}] Idle session ${id} closed after ${constants_js_1.SESSION_IDLE_TIMEOUT_MS}ms`);
127
+ session.destroy();
128
+ this.sessions.delete(id);
129
+ }
130
+ }
131
+ }
132
+ async handleRequest(req, res) {
133
+ if (!this.authorized(req)) {
134
+ res.writeHead(401, { "Content-Type": "application/json" });
135
+ res.end(JSON.stringify({ error: "Unauthorized" }));
136
+ return;
137
+ }
138
+ // Parse once so the route check ignores the query string and we can
139
+ // anchor on pathname — `/servers/foo/extra` must 404, not silently
140
+ // route to `foo` (the proxy's forwarder rejects it, so accepting it
141
+ // here would create a contract mismatch).
142
+ const { pathname } = new URL(req.url ?? "/", "http://h");
143
+ if (req.method === "GET" && pathname === "/") {
144
+ res.writeHead(200, { "Content-Type": "application/json" });
145
+ res.end(JSON.stringify({
146
+ service: "mcp-proxy-host",
147
+ servers: Object.keys(this.config.servers),
148
+ }));
149
+ return;
150
+ }
151
+ const match = pathname.match(/^\/servers\/([^/]+)$/);
152
+ if (!match) {
153
+ res.writeHead(404, { "Content-Type": "application/json" });
154
+ res.end(JSON.stringify({ error: "Not found. Use /servers/<name>" }));
155
+ return;
156
+ }
157
+ const serverName = match[1];
158
+ const serverConfig = this.config.servers[serverName];
159
+ if (!serverConfig) {
160
+ res.writeHead(404, { "Content-Type": "application/json" });
161
+ res.end(JSON.stringify({
162
+ error: `Unknown server: ${serverName}`,
163
+ available: Object.keys(this.config.servers),
164
+ }));
165
+ return;
166
+ }
167
+ if (req.method === "POST") {
168
+ await this.handleMcpPost(req, res, serverName, serverConfig);
169
+ return;
170
+ }
171
+ if (req.method === "GET") {
172
+ this.handleSse(req, res, serverName);
173
+ return;
174
+ }
175
+ if (req.method === "DELETE") {
176
+ const sessionId = req.headers["mcp-session-id"];
177
+ if (sessionId && this.sessions.has(sessionId)) {
178
+ const session = this.sessions.get(sessionId);
179
+ if (session.serverName !== serverName) {
180
+ sendSessionMismatchError(res, session, serverName);
181
+ return;
182
+ }
183
+ session.destroy();
184
+ this.sessions.delete(sessionId);
185
+ }
186
+ res.writeHead(200, { "Content-Type": "application/json" });
187
+ res.end(JSON.stringify({ ok: true }));
188
+ return;
189
+ }
190
+ res.writeHead(405);
191
+ res.end();
192
+ }
193
+ authorized(req) {
194
+ const auth = req.headers.authorization ?? "";
195
+ const expected = `Bearer ${this.authToken}`;
196
+ const authBuf = Buffer.from(auth);
197
+ const expectedBuf = Buffer.from(expected);
198
+ return authBuf.length === expectedBuf.length && (0, node_crypto_1.timingSafeEqual)(authBuf, expectedBuf);
199
+ }
200
+ async handleMcpPost(req, res, serverName, serverConfig) {
201
+ const body = await (0, protocol_js_1.readBody)(req);
202
+ const headerSessionId = req.headers["mcp-session-id"];
203
+ // Peek the JSON-RPC method without consuming the body. Only `initialize`
204
+ // may run without an existing session — anything else against an
205
+ // unknown id is stale (post-GC, post-restart) or wrong, and silently
206
+ // spawning a fresh uninitialized child for it would violate the MCP
207
+ // handshake.
208
+ let method;
209
+ try {
210
+ method = JSON.parse(body).method;
211
+ }
212
+ catch {
213
+ // Unparseable body falls through as non-initialize → 404 below.
214
+ }
215
+ const isInitialize = method === "initialize";
216
+ let existing;
217
+ if (headerSessionId) {
218
+ existing = this.sessions.get(headerSessionId);
219
+ if (existing) {
220
+ if (existing.serverName !== serverName) {
221
+ sendSessionMismatchError(res, existing, serverName);
222
+ return;
223
+ }
224
+ if (!existing.isAlive) {
225
+ // Reaped under us — drop the dead entry; treat as no session.
226
+ this.sessions.delete(headerSessionId);
227
+ existing = undefined;
228
+ }
229
+ }
230
+ }
231
+ let session;
232
+ let activeSessionId;
233
+ if (existing && headerSessionId) {
234
+ session = existing;
235
+ activeSessionId = headerSessionId;
236
+ }
237
+ else {
238
+ if (!isInitialize) {
239
+ // Mirror handleSse: refuse to bind work to an id we don't know.
240
+ // The proxy is expected to re-`initialize` and retry on this 404.
241
+ res.writeHead(404, { "Content-Type": "application/json" });
242
+ res.end(JSON.stringify({
243
+ error: headerSessionId
244
+ ? `Unknown session: ${headerSessionId}`
245
+ : "Mcp-Session-Id header required",
246
+ }));
247
+ return;
248
+ }
249
+ activeSessionId = (0, node_crypto_1.randomBytes)(16).toString("hex");
250
+ session = new session_js_1.McpSession(serverName, serverConfig, this.timeout);
251
+ this.sessions.set(activeSessionId, session);
252
+ }
253
+ const response = await session.sendRequest(body);
254
+ if (!response) {
255
+ // Client notification — no response body
256
+ res.writeHead(202, { "Mcp-Session-Id": activeSessionId });
257
+ res.end();
258
+ return;
259
+ }
260
+ res.writeHead(200, {
261
+ "Content-Type": "application/json",
262
+ "Mcp-Session-Id": activeSessionId,
263
+ });
264
+ res.end(response);
265
+ }
266
+ handleSse(req, res, serverName) {
267
+ const sessionId = req.headers["mcp-session-id"];
268
+ const session = sessionId ? this.sessions.get(sessionId) : undefined;
269
+ // Reject SSE attaches that do not point at a live session for this
270
+ // server. Returning 200 with an empty stream would let the proxy sit
271
+ // on a dead pipe forever instead of reconnecting to a fresh session
272
+ // id — the drain interval below would close the stream on its first
273
+ // tick once it noticed `session.isAlive === false`, but by then the
274
+ // 200 has already convinced the proxy that the session is good and
275
+ // it won't re-initialize on its own. The 404 here is the signal the
276
+ // proxy needs to throw the stale id away and start a fresh session.
277
+ if (sessionId && session && !session.isAlive) {
278
+ // Clear the dead entry on the way out so the next attach sees a
279
+ // clean miss instead of repeating this dance.
280
+ this.sessions.delete(sessionId);
281
+ }
282
+ if (!sessionId || !session || !session.isAlive) {
283
+ res.writeHead(404, { "Content-Type": "application/json" });
284
+ res.end(JSON.stringify({
285
+ error: sessionId ? `Unknown session: ${sessionId}` : "Mcp-Session-Id header required",
286
+ }));
287
+ return;
288
+ }
289
+ if (session.serverName !== serverName) {
290
+ sendSessionMismatchError(res, session, serverName);
291
+ return;
292
+ }
293
+ res.writeHead(200, {
294
+ "Content-Type": "text/event-stream",
295
+ "Cache-Control": "no-cache",
296
+ Connection: "keep-alive",
297
+ "Mcp-Session-Id": sessionId,
298
+ });
299
+ res.write(": connected\n\n");
300
+ const interval = setInterval(() => {
301
+ if (!session.isAlive) {
302
+ clearInterval(interval);
303
+ res.end();
304
+ return;
305
+ }
306
+ const notifications = session.drainNotifications();
307
+ for (const n of notifications) {
308
+ res.write(`data: ${n}\n\n`);
309
+ }
310
+ }, constants_js_1.SSE_DRAIN_INTERVAL_MS);
311
+ req.on("close", () => clearInterval(interval));
312
+ }
313
+ }
314
+ exports.HostAgent = HostAgent;
@@ -0,0 +1 @@
1
+ export declare function main(): Promise<void>;
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.main = main;
4
+ const protocol_js_1 = require("../shared/protocol.js");
5
+ const agent_js_1 = require("./agent.js");
6
+ const tunnel_js_1 = require("./tunnel.js");
7
+ // Entry point: parse flags, start the agent, optionally bring up a quick
8
+ // tunnel, install signal handlers. Kept separate from agent.ts so unit
9
+ // tests / library users can import HostAgent without invoking process.exit
10
+ // or cloudflared.
11
+ async function main() {
12
+ const configPath = (0, protocol_js_1.getArg)("--config") ?? "config.json";
13
+ const timeoutRaw = (0, protocol_js_1.getArg)("--timeout") ?? "120000"; // 2min default
14
+ const timeout = Number(timeoutRaw);
15
+ // setTimeout(fn, NaN | <=0) fires immediately, making every MCP request
16
+ // appear to time out. Reject bad input at startup instead of silently
17
+ // breaking the agent.
18
+ if (!Number.isInteger(timeout) || timeout <= 0) {
19
+ console.error(`Invalid --timeout "${timeoutRaw}": must be a positive integer (milliseconds)`);
20
+ process.exit(2);
21
+ }
22
+ const useTunnel = process.argv.includes("--tunnel");
23
+ // In tunnel mode the listener is internal-only — cloudflared is the sole
24
+ // caller — so we ignore config.host/port and force loopback + an
25
+ // OS-assigned port. That removes the foot-gun where a user-provided port
26
+ // collides with another local service, and prevents accidentally
27
+ // exposing the unauthenticated-from-the-LAN listener on a routable
28
+ // interface when the bearer token is meant to ride only over the tunnel.
29
+ const overrides = useTunnel ? { host: "127.0.0.1", port: 0 } : undefined;
30
+ const agent = new agent_js_1.HostAgent(configPath, timeout, overrides);
31
+ await agent.start();
32
+ let tunnel = null;
33
+ if (useTunnel) {
34
+ console.log("Starting Cloudflare tunnel...");
35
+ try {
36
+ tunnel = await (0, tunnel_js_1.startTunnel)(agent.port, (reason) => {
37
+ // Runtime failure after the tunnel was already serving — keep the
38
+ // local agent alive so loopback clients can still reach it, but
39
+ // make the situation loud so the operator knows the public URL is
40
+ // dead and needs a restart.
41
+ console.error(`Cloudflare tunnel exited unexpectedly: ${reason}`);
42
+ console.error("The public URL is no longer reachable. Restart the host to bring up a new tunnel.");
43
+ });
44
+ }
45
+ catch (err) {
46
+ // Pre-ready tunnel failure (binary missing, network down, auth
47
+ // failure, startup timeout). Without this the user previously saw
48
+ // only "Starting Cloudflare tunnel..." and an apparently healthy
49
+ // host with no URL. Now we tear the agent down and exit non-zero so
50
+ // the failure is visible to whatever launched us.
51
+ console.error(`Cloudflare tunnel failed to start: ${err.message}`);
52
+ try {
53
+ agent.shutdown();
54
+ }
55
+ catch { /* ignore */ }
56
+ process.exit(1);
57
+ }
58
+ }
59
+ let shuttingDown = false;
60
+ const shutdown = (signal) => {
61
+ if (shuttingDown)
62
+ return;
63
+ shuttingDown = true;
64
+ console.log(`Received ${signal}, shutting down...`);
65
+ try {
66
+ agent.shutdown();
67
+ }
68
+ catch (err) {
69
+ console.error(`Agent shutdown error: ${err.message}`);
70
+ }
71
+ if (tunnel) {
72
+ try {
73
+ tunnel.stop();
74
+ }
75
+ catch (err) {
76
+ console.error(`Tunnel stop error: ${err.message}`);
77
+ }
78
+ }
79
+ process.exit(0);
80
+ };
81
+ process.on("SIGINT", () => shutdown("SIGINT"));
82
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
83
+ }
@@ -0,0 +1,4 @@
1
+ export declare const MAX_QUEUED_NOTIFICATIONS = 1000;
2
+ export declare const SESSION_IDLE_TIMEOUT_MS: number;
3
+ export declare const SESSION_GC_INTERVAL_MS: number;
4
+ export declare const SSE_DRAIN_INTERVAL_MS = 100;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SSE_DRAIN_INTERVAL_MS = exports.SESSION_GC_INTERVAL_MS = exports.SESSION_IDLE_TIMEOUT_MS = exports.MAX_QUEUED_NOTIFICATIONS = void 0;
4
+ // Cap on queued notifications per session. Drains happen each time the
5
+ // proxy's SSE reader polls (every 100ms in handleSse). With a sane upstream
6
+ // this stays at a handful of entries; the cap is here to bound memory when
7
+ // the SSE reader is dead/slow and the child is chatty. On overflow we drop
8
+ // the oldest entries — the lost notifications are progress/log noise; any
9
+ // id-bearing response is matched to a pending request before it ever reaches
10
+ // this queue, so request correctness is unaffected.
11
+ exports.MAX_QUEUED_NOTIFICATIONS = 1000;
12
+ // Idle session GC: close sessions that haven't been used in this many ms.
13
+ exports.SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
14
+ exports.SESSION_GC_INTERVAL_MS = 60 * 1000; // sweep every minute
15
+ // SSE poll interval — how often handleSse drains queued notifications.
16
+ exports.SSE_DRAIN_INTERVAL_MS = 100;
@@ -0,0 +1,21 @@
1
+ import { type ServerConfig } from "../shared/protocol.js";
2
+ export declare class McpSession {
3
+ private name;
4
+ private timeout;
5
+ private process;
6
+ private stdoutBuffer;
7
+ private pending;
8
+ private notifications;
9
+ private notificationsDropped;
10
+ private orphansDropped;
11
+ private destroyed;
12
+ lastActivity: number;
13
+ constructor(name: string, config: ServerConfig, timeout: number);
14
+ private failPending;
15
+ private handleLine;
16
+ sendRequest(jsonRpcLine: string): Promise<string>;
17
+ drainNotifications(): string[];
18
+ get serverName(): string;
19
+ get isAlive(): boolean;
20
+ destroy(): void;
21
+ }