@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
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
|
-
|
|
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://
|
|
80
|
+
Configure at: https://abc-xyz.trycloudflare.com/#token=...
|
|
78
81
|
```
|
|
79
82
|
|
|
80
|
-
Open the URL in a browser.
|
|
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
|
|
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.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
155
|
-
|
|
156
|
-
--pages-url <url> Config page URL (default: https://mcp-proxy.pages.dev)
|
|
180
|
+
proxy
|
|
157
181
|
```
|
|
158
182
|
|
|
159
|
-
|
|
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>;
|
package/dist/host/cli.js
ADDED
|
@@ -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,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
|
+
}
|