@silver886/mcp-proxy 0.2.3 → 0.2.5

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 (56) hide show
  1. package/README.md +2 -1
  2. package/dist/host/agent.js +69 -22
  3. package/dist/proxy/core/constants.d.ts +2 -0
  4. package/dist/proxy/core/constants.js +8 -1
  5. package/dist/proxy/discovery/client.js +24 -11
  6. package/dist/proxy/pairing/controller.d.ts +3 -0
  7. package/dist/proxy/pairing/controller.js +56 -4
  8. package/dist/proxy/pairing/http.js +11 -0
  9. package/dist/proxy/runtime/forwarder.js +22 -0
  10. package/dist/proxy/runtime/handlers.d.ts +2 -2
  11. package/dist/proxy/runtime/handlers.js +10 -5
  12. package/dist/proxy/runtime/sse.js +6 -0
  13. package/dist/proxy/runtime/upstream-bridge.js +9 -3
  14. package/dist/proxy/server.js +10 -0
  15. package/dist/shared/protocol.d.ts +7 -0
  16. package/dist/shared/protocol.js +44 -0
  17. package/package.json +17 -3
  18. package/scripts/fetch.mjs +87 -0
  19. package/static/setup.js +86 -25
  20. package/node_modules/cloudflared/LICENSE +0 -21
  21. package/node_modules/cloudflared/README.md +0 -156
  22. package/node_modules/cloudflared/lib/cloudflared.js +0 -10
  23. package/node_modules/cloudflared/lib/constants.js +0 -58
  24. package/node_modules/cloudflared/lib/error.js +0 -32
  25. package/node_modules/cloudflared/lib/handler.js +0 -117
  26. package/node_modules/cloudflared/lib/index.js +0 -126
  27. package/node_modules/cloudflared/lib/install.js +0 -155
  28. package/node_modules/cloudflared/lib/lib.d.ts +0 -236
  29. package/node_modules/cloudflared/lib/lib.js +0 -45
  30. package/node_modules/cloudflared/lib/regex.js +0 -52
  31. package/node_modules/cloudflared/lib/service.js +0 -229
  32. package/node_modules/cloudflared/lib/tunnel.js +0 -164
  33. package/node_modules/cloudflared/lib/types.js +0 -16
  34. package/node_modules/cloudflared/package.json +0 -59
  35. package/node_modules/cloudflared/scripts/postinstall.mjs +0 -10
  36. package/node_modules/eventsource-parser/LICENSE +0 -21
  37. package/node_modules/eventsource-parser/README.md +0 -126
  38. package/node_modules/eventsource-parser/dist/index.cjs +0 -166
  39. package/node_modules/eventsource-parser/dist/index.cjs.map +0 -1
  40. package/node_modules/eventsource-parser/dist/index.d.cts +0 -146
  41. package/node_modules/eventsource-parser/dist/index.d.ts +0 -146
  42. package/node_modules/eventsource-parser/dist/index.js +0 -166
  43. package/node_modules/eventsource-parser/dist/index.js.map +0 -1
  44. package/node_modules/eventsource-parser/dist/stream.cjs +0 -28
  45. package/node_modules/eventsource-parser/dist/stream.cjs.map +0 -1
  46. package/node_modules/eventsource-parser/dist/stream.d.cts +0 -121
  47. package/node_modules/eventsource-parser/dist/stream.d.ts +0 -121
  48. package/node_modules/eventsource-parser/dist/stream.js +0 -29
  49. package/node_modules/eventsource-parser/dist/stream.js.map +0 -1
  50. package/node_modules/eventsource-parser/package.json +0 -92
  51. package/node_modules/eventsource-parser/src/errors.ts +0 -44
  52. package/node_modules/eventsource-parser/src/index.ts +0 -3
  53. package/node_modules/eventsource-parser/src/parse.ts +0 -395
  54. package/node_modules/eventsource-parser/src/stream.ts +0 -88
  55. package/node_modules/eventsource-parser/src/types.ts +0 -97
  56. package/node_modules/eventsource-parser/stream.js +0 -2
package/README.md CHANGED
@@ -106,7 +106,8 @@ single-proxy, in line with MCP's one-server-one-client model.)
106
106
  2. Agent calls the `configure` tool. Proxy spawns a Node wrapper that owns
107
107
  a `cloudflared` quick tunnel pointing at a local pairing HTTP server.
108
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.
109
+ (POST /pair/list-servers, POST /pair/discover, POST /pair/complete) on
110
+ the same origin.
110
111
  3. Wrapper prints the tunnel URL. Proxy mints a bearer token and emits a
111
112
  setup URL — `<tunnel>/#token=<token>`. Token rides in the URL fragment
112
113
  so it never appears in server access logs or Referer headers.
@@ -29,25 +29,63 @@ class HostAgent {
29
29
  boundPort;
30
30
  constructor(configPath, timeout, overrides) {
31
31
  const raw = (0, node_fs_1.readFileSync)(configPath, "utf-8");
32
- this.config = JSON.parse(raw);
32
+ const parsed = JSON.parse(raw);
33
33
  this.timeout = timeout;
34
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.
35
+ // Single boundary-time validation pass: server-name policy, per-entry
36
+ // shape, and top-level host/port. Documented defaults (args=[]) are
37
+ // installed by normalizeServerConfig so downstream consumers can trust
38
+ // the ServerConfig contract instead of re-checking shapes without
39
+ // this, omitting `args` (legal per README) crashes McpSession deep
40
+ // inside `args.join(" ")`. All reasons are accumulated so a broken
41
+ // file surfaces a complete diff to fix in one error message rather
42
+ // than one-issue-per-restart.
41
43
  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}`);
44
+ const servers = {};
45
+ if (!parsed.servers || typeof parsed.servers !== "object" || Array.isArray(parsed.servers)) {
46
+ invalid.push(` - "servers": must be an object map of name to config`);
47
+ }
48
+ else {
49
+ const rawServers = parsed.servers;
50
+ if (Object.keys(rawServers).length === 0) {
51
+ invalid.push(` - "servers": at least one server must be declared`);
52
+ }
53
+ for (const [name, entry] of Object.entries(rawServers)) {
54
+ const nameReason = (0, protocol_js_1.validateServerName)(name);
55
+ if (nameReason)
56
+ invalid.push(` - "${name}": ${nameReason}`);
57
+ const result = (0, protocol_js_1.normalizeServerConfig)(entry);
58
+ if (!result.ok) {
59
+ for (const reason of result.reasons)
60
+ invalid.push(` - "${name}": ${reason}`);
61
+ }
62
+ else if (!nameReason) {
63
+ servers[name] = result.config;
64
+ }
65
+ }
66
+ }
67
+ if (parsed.host !== undefined && typeof parsed.host !== "string") {
68
+ invalid.push(` - "host": must be a string (default: ${protocol_js_1.DEFAULT_HOST})`);
69
+ }
70
+ if (parsed.port !== undefined
71
+ && (typeof parsed.port !== "number"
72
+ || !Number.isInteger(parsed.port)
73
+ || parsed.port < 0
74
+ || parsed.port > 65535)) {
75
+ invalid.push(` - "port": must be an integer 0–65535 (default: ${protocol_js_1.DEFAULT_PORT})`);
46
76
  }
47
77
  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.`);
78
+ throw new Error(`Invalid host config in ${configPath}:\n${invalid.join("\n")}\n` +
79
+ `Fix the entries in config.json so they match the documented schema.`);
50
80
  }
81
+ this.config = {
82
+ servers,
83
+ host: typeof parsed.host === "string" ? parsed.host : undefined,
84
+ port: typeof parsed.port === "number" ? parsed.port : undefined,
85
+ };
86
+ this.boundHost = overrides?.host ?? this.config.host ?? protocol_js_1.DEFAULT_HOST;
87
+ // port 0 = let the OS pick. Resolved to the real bound port in start().
88
+ this.boundPort = overrides?.port ?? this.config.port ?? protocol_js_1.DEFAULT_PORT;
51
89
  }
52
90
  get port() {
53
91
  return this.boundPort;
@@ -200,19 +238,28 @@ class HostAgent {
200
238
  async handleMcpPost(req, res, serverName, serverConfig) {
201
239
  const body = await (0, protocol_js_1.readBody)(req);
202
240
  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;
241
+ // Parse once at the HTTP boundary. The host is the JSON-RPC endpoint
242
+ // from the proxy's perspective, so a malformed body must surface as a
243
+ // spec-compliant parse-error response with id:null not get forwarded
244
+ // to the child as a notification. Without this gate the body falls
245
+ // through sendRequest's `id === undefined` branch (notification path),
246
+ // garbage hits stdin, the caller gets a misleading 202, and the
247
+ // child's parse-error reply arrives with id:null and is dropped as an
248
+ // orphan — guaranteeing a silent timeout on the proxy.
249
+ let parsedBody;
209
250
  try {
210
- method = JSON.parse(body).method;
251
+ parsedBody = JSON.parse(body);
211
252
  }
212
253
  catch {
213
- // Unparseable body falls through as non-initialize 404 below.
254
+ res.writeHead(200, { "Content-Type": "application/json" });
255
+ res.end((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PARSE_ERROR, undefined, null));
256
+ return;
214
257
  }
215
- const isInitialize = method === "initialize";
258
+ // Only `initialize` may run without an existing session — anything
259
+ // else against an unknown id is stale (post-GC, post-restart) or
260
+ // wrong, and silently spawning a fresh uninitialized child for it
261
+ // would violate the MCP handshake.
262
+ const isInitialize = parsedBody.method === "initialize";
216
263
  let existing;
217
264
  if (headerSessionId) {
218
265
  existing = this.sessions.get(headerSessionId);
@@ -6,6 +6,8 @@ export declare const UPSTREAM_REQUEST_TIMEOUT_MS = 120000;
6
6
  export declare const DISCOVERY_FETCH_TIMEOUT_MS = 15000;
7
7
  export declare const TOOL_FORWARD_TIMEOUT_MS: number;
8
8
  export declare const SESSION_DELETE_TIMEOUT_MS = 5000;
9
+ export declare const PAIRING_HEADERS_TIMEOUT_MS = 30000;
10
+ export declare const PAIRING_REQUEST_TIMEOUT_MS = 60000;
9
11
  export declare const SSE_BACKOFF_INITIAL_MS = 500;
10
12
  export declare const SSE_BACKOFF_MAX_MS = 10000;
11
13
  export declare const SSE_CONNECT_TIMEOUT_MS = 15000;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
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;
3
+ exports.CONFIGURE_PROMPT = exports.CONFIGURE_TOOL = exports.SSE_CONNECT_TIMEOUT_MS = exports.SSE_BACKOFF_MAX_MS = exports.SSE_BACKOFF_INITIAL_MS = exports.PAIRING_REQUEST_TIMEOUT_MS = exports.PAIRING_HEADERS_TIMEOUT_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
4
  const protocol_js_1 = require("../../shared/protocol.js");
5
5
  exports.TOOL_SEPARATOR = protocol_js_1.TOOL_NAME_SEPARATOR;
6
6
  // Pairing-tunnel + bridging budgets.
@@ -15,6 +15,13 @@ exports.UPSTREAM_REQUEST_TIMEOUT_MS = 120_000; // server→client bridge
15
15
  exports.DISCOVERY_FETCH_TIMEOUT_MS = 15_000;
16
16
  exports.TOOL_FORWARD_TIMEOUT_MS = 5 * 60 * 1000;
17
17
  exports.SESSION_DELETE_TIMEOUT_MS = 5_000;
18
+ // Pairing HTTP server per-request budgets. Pairing payloads are small JSON
19
+ // blobs (host creds, selected servers/tools), so a slow header or body
20
+ // phase is broken or hostile rather than legitimate. Bounding both prevents
21
+ // a slow upload from outliving PAIRING_WINDOW_MS via Node's defaults
22
+ // (headersTimeout 60s, requestTimeout 5min).
23
+ exports.PAIRING_HEADERS_TIMEOUT_MS = 30_000;
24
+ exports.PAIRING_REQUEST_TIMEOUT_MS = 60_000;
18
25
  exports.SSE_BACKOFF_INITIAL_MS = 500;
19
26
  exports.SSE_BACKOFF_MAX_MS = 10_000;
20
27
  // Bound the connect+headers phase only. A blackholed tunnel would otherwise
@@ -13,6 +13,17 @@ exports.listHostServers = listHostServers;
13
13
  const protocol_js_1 = require("../../shared/protocol.js");
14
14
  const constants_js_1 = require("../core/constants.js");
15
15
  const fetch_timeout_js_1 = require("../core/fetch-timeout.js");
16
+ // Discovery helpers. List calls (tools/prompts/resources/templates)
17
+ // distinguish METHOD_NOT_FOUND ("feature absent" → []) from any other
18
+ // failure (transport blip, JSON-RPC error, malformed body → throw). The
19
+ // caller decides whether to preserve cached state, mark a per-capability
20
+ // retry flag, or fail outright. Every fetch is wrapped in
21
+ // DISCOVERY_FETCH_TIMEOUT_MS so a host that accepts the connection then
22
+ // hangs can't pin the proxy.
23
+ // JSON-RPC METHOD_NOT_FOUND — what an MCP server returns when it doesn't
24
+ // support a capability. Treated as "feature absent" (empty list), distinct
25
+ // from a transport blip which the strict variants surface as a throw.
26
+ const METHOD_NOT_FOUND = -32601;
16
27
  async function initializeServer(targetUrl, baseHeaders, name, clientCapabilities, clientInfo) {
17
28
  const resp = await fetch(targetUrl, {
18
29
  method: "POST",
@@ -81,14 +92,13 @@ async function fetchTools(targetUrl, headers, name) {
81
92
  throw new Error(`tools/list returned malformed JSON: ${err.message}`);
82
93
  }
83
94
  const error = data.error;
84
- if (error)
95
+ if (error) {
96
+ if (error.code === METHOD_NOT_FOUND)
97
+ return [];
85
98
  throw new Error(`tools/list error: ${error.message ?? JSON.stringify(error)}`);
99
+ }
86
100
  return extractListField(data, "tools/list", "tools");
87
101
  }
88
- // JSON-RPC METHOD_NOT_FOUND — what an MCP server returns when it doesn't
89
- // support a capability. Treated as "feature absent" (empty list), distinct
90
- // from a transport blip which the strict variants surface as a throw.
91
- const METHOD_NOT_FOUND = -32601;
92
102
  // Run a tools/prompts/resources-style list call against the upstream and
93
103
  // extract the list field. Throws on any transport, parse, or JSON-RPC
94
104
  // failure other than METHOD_NOT_FOUND, which collapses to []. Callers
@@ -148,12 +158,15 @@ class DiscoveryError extends Error {
148
158
  }
149
159
  exports.DiscoveryError = DiscoveryError;
150
160
  // Single source of truth for the per-server MCP handshake: initialize →
151
- // notifications/initialized → tools/list (required) → prompts / resources /
152
- // templates (each optional, recorded as pending on failure). Used by both
153
- // the runtime discovery path and the pairing-mediated discovery endpoint
154
- // so the browser sees the same capability set the proxy will see at
155
- // runtime including using the real MCP client's capabilities/clientInfo
156
- // rather than synthetic browser values.
161
+ // notifications/initialized → tools / prompts / resources / templates
162
+ // (each optional METHOD_NOT_FOUND collapses to [], other failures on
163
+ // prompts/resources/templates are recorded as pending; tools failures
164
+ // other than METHOD_NOT_FOUND are still fatal because tools/list is the
165
+ // canonical "is this server alive" probe). Used by both the runtime
166
+ // discovery path and the pairing-mediated discovery endpoint so the
167
+ // browser sees the same capability set the proxy will see at runtime —
168
+ // including using the real MCP client's capabilities/clientInfo rather
169
+ // than synthetic browser values.
157
170
  async function discoverServerCapabilities(targetUrl, baseHeaders, name, clientCapabilities, clientInfo, log) {
158
171
  let sessionId;
159
172
  try {
@@ -8,12 +8,15 @@ export declare class PairingController {
8
8
  private readonly log;
9
9
  private readonly sendNotification;
10
10
  private pairing;
11
+ private configureInflight;
11
12
  constructor(state: ProxyState, runner: DiscoveryRunner, bridge: UpstreamBridge, log: (line: string) => void, sendNotification: (method: string) => void);
12
13
  handleConfigure(): Promise<string>;
14
+ private doConfigure;
13
15
  teardownPairing(): void;
14
16
  private validateHostCreds;
15
17
  private handleListServers;
16
18
  private handleDiscover;
17
19
  private handleComplete;
20
+ private notifyAllListsChanged;
18
21
  closeAllSessions(): Promise<void>;
19
22
  }
@@ -22,6 +22,13 @@ class PairingController {
22
22
  log;
23
23
  sendNotification;
24
24
  pairing = null;
25
+ // Single-flight gate for handleConfigure. Without it, two `configure`
26
+ // calls landing in the same event-loop window both observe `this.pairing`
27
+ // as null, both spawn an HTTP server + cloudflared subprocess, and the
28
+ // second's assignment to `this.pairing` orphans the first. The orphan's
29
+ // expiryTimer would then later call teardownPairing() and tear down the
30
+ // wrong (active) pairing, killing a working setup mid-session.
31
+ configureInflight = null;
25
32
  constructor(state, runner, bridge, log, sendNotification) {
26
33
  this.state = state;
27
34
  this.runner = runner;
@@ -29,7 +36,15 @@ class PairingController {
29
36
  this.log = log;
30
37
  this.sendNotification = sendNotification;
31
38
  }
32
- async handleConfigure() {
39
+ handleConfigure() {
40
+ if (this.configureInflight)
41
+ return this.configureInflight;
42
+ this.configureInflight = this.doConfigure().finally(() => {
43
+ this.configureInflight = null;
44
+ });
45
+ return this.configureInflight;
46
+ }
47
+ async doConfigure() {
33
48
  this.teardownPairing();
34
49
  const bearer = (0, node_crypto_1.randomBytes)(32).toString("base64url");
35
50
  const http = new http_js_1.PairingHttpServer(bearer, {
@@ -257,6 +272,22 @@ class PairingController {
257
272
  missing.push(`${host.config.id} (no servers)`);
258
273
  }
259
274
  }
275
+ // selectedTools entries are validated structurally by validatePairingConfig,
276
+ // but their existence on the wire can only be checked after discovery has
277
+ // populated state.toolRoute. The browser path can't produce stale entries
278
+ // (the UI builds selectedTools from the just-discovered set), but a direct
279
+ // caller of /pair/complete can — and getFilteredTools silently drops any
280
+ // entry whose key isn't in toolRoute, leaving the proxy paired-but-empty
281
+ // for tools with no error surfaced. Treat stale entries the same as
282
+ // missing servers: roll back to the previous pairing (or refuse, on
283
+ // first-time pair) so the operator gets a real failure signal instead of
284
+ // a cheerful ok on a config that won't expose any tools.
285
+ if (cfg.selectedTools) {
286
+ for (const key of cfg.selectedTools) {
287
+ if (!this.state.toolRoute.has(key))
288
+ missing.push(key);
289
+ }
290
+ }
260
291
  if (missing.length > 0) {
261
292
  // Cap the detail string so a wildly broken submit doesn't produce a
262
293
  // multi-kilobyte error body; the operator only needs a few names to
@@ -269,6 +300,12 @@ class PairingController {
269
300
  this.log(` New pairing missing servers (${detail}); restoring previous pairing`);
270
301
  this.state.installConfig(previousConfig, previousHosts);
271
302
  await this.runner.discoverServers();
303
+ // installConfig replaced toolRoute / promptRoute / resources between
304
+ // the success-path notify (only fires when missing.length === 0) and
305
+ // here, so an agent that polled tools/list during the new pairing's
306
+ // discovery may be holding a partial snapshot that no longer matches
307
+ // the restored routes. Notify list_changed so it re-fetches.
308
+ this.notifyAllListsChanged();
272
309
  return {
273
310
  ok: false,
274
311
  error: `Discovery did not complete for: ${detail}. The previous pairing has been restored — verify host reachability and retry.`,
@@ -279,6 +316,10 @@ class PairingController {
279
316
  // so the next configure call starts fresh).
280
317
  this.log(` New pairing missing servers (${detail}) and no prior config to restore; reverting to unconfigured`);
281
318
  this.state.config = null;
319
+ // Same notify rationale as the rollback branch above: a polling agent
320
+ // may have grabbed partial-new routes during discovery; tell it to
321
+ // re-fetch and find an empty unconfigured set.
322
+ this.notifyAllListsChanged();
282
323
  return {
283
324
  ok: false,
284
325
  error: `Discovery did not complete for: ${detail}. Verify host reachability and retry.`,
@@ -289,16 +330,27 @@ class PairingController {
289
330
  // Discovery already populated the aggregated lists. Notify the agent
290
331
  // so it re-fetches tools/prompts/resources instead of trusting any
291
332
  // cached empty lists from before pairing.
292
- this.sendNotification("notifications/tools/list_changed");
293
- this.sendNotification("notifications/prompts/list_changed");
294
- this.sendNotification("notifications/resources/list_changed");
333
+ this.notifyAllListsChanged();
295
334
  // Defer pairing teardown until /pair/complete's response body has
296
335
  // actually drained to the client — a fixed timer races slow clients
297
336
  // (mobile data, congested tunnel) and they see a dropped connection
298
337
  // on a successful pair.
299
338
  return { ok: true, afterFlush: () => this.teardownPairing() };
300
339
  }
340
+ notifyAllListsChanged() {
341
+ this.sendNotification("notifications/tools/list_changed");
342
+ this.sendNotification("notifications/prompts/list_changed");
343
+ this.sendNotification("notifications/resources/list_changed");
344
+ }
301
345
  async closeAllSessions() {
346
+ // Bump the supersession token before any await so an in-flight forwarder
347
+ // request that resolves during teardown sees a stale generation and
348
+ // refuses to write its response. Without this, a fetch completing during
349
+ // bridge.clear / DELETE awaits emits onto stdout against a pairing that
350
+ // no longer exists. installConfig() bumps again on the way in; the
351
+ // double-bump is harmless because the token is only used for equality
352
+ // comparison.
353
+ this.state.configGeneration++;
302
354
  // Tear down SSE listeners first so loops don't reconnect after DELETE.
303
355
  for (const host of this.state.hosts.values()) {
304
356
  for (const ctrl of host.sseControllers.values())
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PairingHttpServer = void 0;
4
4
  const node_crypto_1 = require("node:crypto");
5
5
  const protocol_js_1 = require("../../shared/protocol.js");
6
+ const constants_js_1 = require("../core/constants.js");
6
7
  const static_assets_js_1 = require("./static-assets.js");
7
8
  class PairingHttpServer {
8
9
  bearer;
@@ -17,6 +18,11 @@ class PairingHttpServer {
17
18
  listen() {
18
19
  return new Promise((resolveP, rejectP) => {
19
20
  const srv = (0, protocol_js_1.createServer)((req, res) => this.handle(req, res));
21
+ // Bound the header + body phases so a slow upload can't drag past
22
+ // PAIRING_WINDOW_MS. Node defaults (60s / 5min) are too generous for
23
+ // small JSON pairing payloads.
24
+ srv.headersTimeout = constants_js_1.PAIRING_HEADERS_TIMEOUT_MS;
25
+ srv.requestTimeout = constants_js_1.PAIRING_REQUEST_TIMEOUT_MS;
20
26
  srv.once("error", rejectP);
21
27
  srv.listen(0, "127.0.0.1", () => {
22
28
  const addr = srv.address();
@@ -32,6 +38,11 @@ class PairingHttpServer {
32
38
  close() {
33
39
  if (!this.server)
34
40
  return;
41
+ // server.close() alone only refuses new connections; idle keep-alive
42
+ // sockets and any in-flight request body would otherwise outlive the
43
+ // pairing window. closeAllConnections() (Node ≥18.2) hard-drops them
44
+ // so teardown is bounded by the window, not by the slowest client.
45
+ this.server.closeAllConnections();
35
46
  this.server.close();
36
47
  this.server = null;
37
48
  }
@@ -39,6 +39,13 @@ class Forwarder {
39
39
  return h;
40
40
  };
41
41
  const body = JSON.stringify({ jsonrpc: "2.0", id, method, params: upstreamParams });
42
+ // Snapshot the active pairing so a re-pair landing while this fetch is
43
+ // in flight can be detected before we write a stale response back to the
44
+ // agent. closeAllSessions clears state.inflight, but our local closure
45
+ // still holds the id/route, so without this guard the OLD pairing's
46
+ // response (or its upstream-level error) would be emitted on stdout
47
+ // against a pairing that no longer exists.
48
+ const startGeneration = this.state.configGeneration;
42
49
  this.state.inflight.set(id, route);
43
50
  const progressToken = (upstreamParams?._meta?.progressToken);
44
51
  if (progressToken !== undefined)
@@ -82,6 +89,14 @@ class Forwarder {
82
89
  this.runner.captureSessionId(host, route.serverName, refreshed, upstream.headers.get("mcp-session-id"));
83
90
  responseBody = await upstream.text();
84
91
  }
92
+ // Pairing changed underneath us between dispatch and response. Reply
93
+ // with a generic error so the request doesn't hang — the agent can
94
+ // retry against the new pairing — but don't write the stale body or
95
+ // its upstream-level error, since neither applies to the new config.
96
+ if (this.state.configGeneration !== startGeneration) {
97
+ this.sendError(protocol_js_1.ErrorCode.INTERNAL, "request superseded by reconfiguration", id);
98
+ return;
99
+ }
85
100
  if (!upstream.ok) {
86
101
  this.sendError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, `host returned ${upstream.status}: ${responseBody.slice(0, 200)}`, id);
87
102
  return;
@@ -137,6 +152,13 @@ class Forwarder {
137
152
  this.writeOut(JSON.stringify(parsed));
138
153
  }
139
154
  catch (err) {
155
+ // Same supersession guard as the success path: if the abort/error
156
+ // raced a re-pair, the agent should see "superseded" rather than the
157
+ // raw transport error from a pairing that no longer exists.
158
+ if (this.state.configGeneration !== startGeneration) {
159
+ this.sendError(protocol_js_1.ErrorCode.INTERNAL, "request superseded by reconfiguration", id);
160
+ return;
161
+ }
140
162
  this.sendError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, err.message, id);
141
163
  }
142
164
  finally {
@@ -40,9 +40,9 @@ export declare class RequestHandlers {
40
40
  argument?: unknown;
41
41
  }): Promise<void>;
42
42
  handleToolDispatch(id: string | number, params: {
43
- name: string;
43
+ name?: string;
44
44
  arguments?: Record<string, unknown>;
45
45
  _meta?: unknown;
46
- }): Promise<void>;
46
+ } | undefined): Promise<void>;
47
47
  handleClientNotification(method: string, params: Record<string, unknown>): Promise<void>;
48
48
  }
@@ -241,7 +241,12 @@ class RequestHandlers {
241
241
  });
242
242
  }
243
243
  async handleToolDispatch(id, params) {
244
- if (params.name === "configure") {
244
+ if (!params || typeof params.name !== "string") {
245
+ this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "name is required", id);
246
+ return;
247
+ }
248
+ const toolName = params.name;
249
+ if (toolName === "configure") {
245
250
  let text;
246
251
  try {
247
252
  text = await this.pairing.handleConfigure();
@@ -257,14 +262,14 @@ class RequestHandlers {
257
262
  this.sendError(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, "Call the `configure` tool first.", id);
258
263
  return;
259
264
  }
260
- const route = this.state.toolRoute.get(params.name);
265
+ const route = this.state.toolRoute.get(toolName);
261
266
  if (!route || !(0, filtering_js_1.isServerSelected)(this.state.config, route.hostId, route.serverName)) {
262
- this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${params.name}`, id);
267
+ this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${toolName}`, id);
263
268
  return;
264
269
  }
265
270
  // selectedTools is a tool-level filter on top of the server-level gate.
266
- if (this.state.config.selectedTools !== undefined && !this.state.config.selectedTools.includes(params.name)) {
267
- this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${params.name}`, id);
271
+ if (this.state.config.selectedTools !== undefined && !this.state.config.selectedTools.includes(toolName)) {
272
+ this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${toolName}`, id);
268
273
  return;
269
274
  }
270
275
  // Preserve `_meta` so the upstream server still sees the agent's
@@ -132,6 +132,12 @@ class SseReader {
132
132
  this.cb.onUpstreamRequest(host, name, msg);
133
133
  return;
134
134
  }
135
+ // Symmetric with server.ts: an upstream "request" with id:null can't
136
+ // be answered (the bridge keys responses by id), so drop rather than
137
+ // forwarding it to the agent as if it were a notification — that
138
+ // would silently re-cast a request the upstream expects a response to.
139
+ if (hasMethod && msg.id === null)
140
+ return;
135
141
  if (!hasMethod)
136
142
  return; // stray response — not expected on this stream
137
143
  // Notifications: list_changed events trigger a cache refresh BEFORE
@@ -106,21 +106,27 @@ class UpstreamBridge {
106
106
  const host = hosts.get(ctx.hostId);
107
107
  if (!host)
108
108
  return;
109
+ // Teardown courtesy — share the DELETE budget, not the 5-min tool
110
+ // budget. A blackholed host would otherwise stall closeAllSessions
111
+ // (re-pair, rollback, SIGTERM shutdown) until TOOL_FORWARD_TIMEOUT_MS.
112
+ // The DELETE that follows reaps the session anyway; if this courtesy
113
+ // doesn't land quickly the upstream's own UPSTREAM_REQUEST_TIMEOUT_MS
114
+ // catches the orphaned child.
109
115
  return this.postResponse(host, ctx.serverName, ctx.sessionId, {
110
116
  jsonrpc: "2.0",
111
117
  id: ctx.originalId,
112
118
  error: { code: protocol_js_1.ErrorCode.INTERNAL, message: "proxy reconfigured before client responded" },
113
- });
119
+ }, constants_js_1.SESSION_DELETE_TIMEOUT_MS);
114
120
  }));
115
121
  }
116
- async postResponse(host, serverName, sessionId, body) {
122
+ async postResponse(host, serverName, sessionId, body, timeoutMs = constants_js_1.TOOL_FORWARD_TIMEOUT_MS) {
117
123
  const target = `${host.config.tunnelUrl}/servers/${serverName}`;
118
124
  const headers = { ...this.hostHeaders(host.config), "Mcp-Session-Id": sessionId };
119
125
  try {
120
126
  const resp = await fetch(target, {
121
127
  method: "POST",
122
128
  headers,
123
- signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
129
+ signal: (0, fetch_timeout_js_1.timeoutSignal)(timeoutMs),
124
130
  body: JSON.stringify(body),
125
131
  });
126
132
  this.captureSessionId(host, serverName, resp.headers.get("mcp-session-id"));
@@ -120,6 +120,16 @@ class ProxyServer {
120
120
  process.stdout.write((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.INVALID_REQUEST, "missing method", parsed.id ?? null) + "\n");
121
121
  return;
122
122
  }
123
+ // JSON-RPC 2.0 discourages id:null in requests because the spec
124
+ // reserves null for "id couldn't be parsed" in error responses
125
+ // (we use it ourselves at the PARSE_ERROR / missing-method paths).
126
+ // Falling through to the notification branch here would silently
127
+ // drop a request the agent expects an answer to; reject explicitly
128
+ // so the agent sees a real error instead of hanging.
129
+ if (parsed.id === null) {
130
+ process.stdout.write((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.INVALID_REQUEST, "id must not be null in a request", null) + "\n");
131
+ return;
132
+ }
123
133
  if (!hasId) {
124
134
  await this.handlers.handleClientNotification(parsed.method, parsed.params ?? {});
125
135
  return;
@@ -50,6 +50,13 @@ export interface ServerConfig {
50
50
  env?: Record<string, string>;
51
51
  shell?: boolean;
52
52
  }
53
+ export declare function normalizeServerConfig(raw: unknown): {
54
+ ok: true;
55
+ config: ServerConfig;
56
+ } | {
57
+ ok: false;
58
+ reasons: string[];
59
+ };
53
60
  export interface HostAgentConfig {
54
61
  servers: Record<string, ServerConfig>;
55
62
  host?: string;
@@ -6,6 +6,7 @@ exports.readBody = readBody;
6
6
  exports.getArg = getArg;
7
7
  exports.createServer = createServer;
8
8
  exports.validateServerName = validateServerName;
9
+ exports.normalizeServerConfig = normalizeServerConfig;
9
10
  const node_fs_1 = require("node:fs");
10
11
  const node_http_1 = require("node:http");
11
12
  const node_path_1 = require("node:path");
@@ -181,3 +182,46 @@ function validateServerName(name) {
181
182
  }
182
183
  return null;
183
184
  }
185
+ // Validates a raw ServerConfig from JSON and returns the canonical form
186
+ // with documented defaults installed (args=[]). Every consumer of
187
+ // ServerConfig — McpSession's spawn, the log line, future tooling — can
188
+ // then trust the interface contract instead of re-checking shapes
189
+ // defensively at every use site. All shape reasons are collected so a
190
+ // misconfigured file surfaces a complete diff to fix in one error
191
+ // message rather than one-issue-per-restart.
192
+ function normalizeServerConfig(raw) {
193
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
194
+ return { ok: false, reasons: ["must be an object with at least { command }"] };
195
+ }
196
+ const r = raw;
197
+ const reasons = [];
198
+ if (typeof r.command !== "string" || r.command.length === 0) {
199
+ reasons.push("command must be a non-empty string");
200
+ }
201
+ if (r.args !== undefined
202
+ && !(Array.isArray(r.args) && r.args.every((a) => typeof a === "string"))) {
203
+ reasons.push("args must be an array of strings (default: [])");
204
+ }
205
+ if (r.env !== undefined) {
206
+ const envOk = typeof r.env === "object"
207
+ && r.env !== null
208
+ && !Array.isArray(r.env)
209
+ && Object.values(r.env).every((v) => typeof v === "string");
210
+ if (!envOk)
211
+ reasons.push("env must be a map of string to string (default: {})");
212
+ }
213
+ if (r.shell !== undefined && typeof r.shell !== "boolean") {
214
+ reasons.push("shell must be boolean (default: false)");
215
+ }
216
+ if (reasons.length > 0)
217
+ return { ok: false, reasons };
218
+ return {
219
+ ok: true,
220
+ config: {
221
+ command: r.command,
222
+ args: r.args ?? [],
223
+ env: r.env,
224
+ shell: r.shell,
225
+ },
226
+ };
227
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silver886/mcp-proxy",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "MCP proxy bridge: forward MCP requests across network boundaries via Cloudflare tunnel",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,6 +31,7 @@
31
31
  },
32
32
  "files": [
33
33
  "dist",
34
+ "scripts/fetch.mjs",
34
35
  "static"
35
36
  ],
36
37
  "engines": {
@@ -42,10 +43,23 @@
42
43
  },
43
44
  "devDependencies": {
44
45
  "@types/node": "^20.0.0",
46
+ "js-yaml": "^4.1.0",
45
47
  "typescript": "^5.5.0"
46
48
  },
47
- "bundleDependencies": true,
49
+ "lockedDependencies": {
50
+ "cloudflared": {
51
+ "version": "0.7.1",
52
+ "tarball": "https://registry.npmjs.org/cloudflared/-/cloudflared-0.7.1.tgz",
53
+ "integrity": "sha512-jJn1Gu9Tf4qnIu8tfiHZ25Hs8rNcRYSVf8zAd97wvYdOCzftm1CTs1S/RPhijjGi8gUT1p9yzfDi9zYlU/0RwA=="
54
+ },
55
+ "eventsource-parser": {
56
+ "version": "3.0.8",
57
+ "tarball": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
58
+ "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="
59
+ }
60
+ },
48
61
  "scripts": {
49
- "build": "tsc"
62
+ "build": "tsc",
63
+ "test": "tsc --noEmit"
50
64
  }
51
65
  }