@silver886/mcp-proxy 0.2.4 → 0.2.6

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 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.
@@ -19,4 +19,6 @@ export declare class HostAgent {
19
19
  private authorized;
20
20
  private handleMcpPost;
21
21
  private handleSse;
22
+ private handleAdminRestart;
23
+ private restartServer;
22
24
  }
@@ -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;
@@ -148,6 +186,15 @@ class HostAgent {
148
186
  }));
149
187
  return;
150
188
  }
189
+ if (pathname === "/admin/restart") {
190
+ if (req.method !== "POST") {
191
+ res.writeHead(405);
192
+ res.end();
193
+ return;
194
+ }
195
+ await this.handleAdminRestart(req, res);
196
+ return;
197
+ }
151
198
  const match = pathname.match(/^\/servers\/([^/]+)$/);
152
199
  if (!match) {
153
200
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -200,19 +247,28 @@ class HostAgent {
200
247
  async handleMcpPost(req, res, serverName, serverConfig) {
201
248
  const body = await (0, protocol_js_1.readBody)(req);
202
249
  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;
250
+ // Parse once at the HTTP boundary. The host is the JSON-RPC endpoint
251
+ // from the proxy's perspective, so a malformed body must surface as a
252
+ // spec-compliant parse-error response with id:null not get forwarded
253
+ // to the child as a notification. Without this gate the body falls
254
+ // through sendRequest's `id === undefined` branch (notification path),
255
+ // garbage hits stdin, the caller gets a misleading 202, and the
256
+ // child's parse-error reply arrives with id:null and is dropped as an
257
+ // orphan — guaranteeing a silent timeout on the proxy.
258
+ let parsedBody;
209
259
  try {
210
- method = JSON.parse(body).method;
260
+ parsedBody = JSON.parse(body);
211
261
  }
212
262
  catch {
213
- // Unparseable body falls through as non-initialize 404 below.
263
+ res.writeHead(200, { "Content-Type": "application/json" });
264
+ res.end((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PARSE_ERROR, undefined, null));
265
+ return;
214
266
  }
215
- const isInitialize = method === "initialize";
267
+ // Only `initialize` may run without an existing session — anything
268
+ // else against an unknown id is stale (post-GC, post-restart) or
269
+ // wrong, and silently spawning a fresh uninitialized child for it
270
+ // would violate the MCP handshake.
271
+ const isInitialize = parsedBody.method === "initialize";
216
272
  let existing;
217
273
  if (headerSessionId) {
218
274
  existing = this.sessions.get(headerSessionId);
@@ -310,5 +366,62 @@ class HostAgent {
310
366
  }, constants_js_1.SSE_DRAIN_INTERVAL_MS);
311
367
  req.on("close", () => clearInterval(interval));
312
368
  }
369
+ // POST /admin/restart body: { "server": "<name>" }
370
+ // Kills every live session for that server. The next forward from the
371
+ // proxy will see a 404 (because the session id is gone from the map),
372
+ // re-`initialize` via the proxy's existing stale-session recovery, and
373
+ // spawn a fresh child. The host doesn't need to know about hosts/aliases
374
+ // — it trusts the bearer for scoping.
375
+ async handleAdminRestart(req, res) {
376
+ const body = await (0, protocol_js_1.readBody)(req);
377
+ let parsed;
378
+ try {
379
+ parsed = JSON.parse(body);
380
+ }
381
+ catch {
382
+ res.writeHead(400, { "Content-Type": "application/json" });
383
+ res.end(JSON.stringify({ error: "server is required" }));
384
+ return;
385
+ }
386
+ const serverName = parsed.server;
387
+ if (typeof serverName !== "string" || serverName.length === 0) {
388
+ res.writeHead(400, { "Content-Type": "application/json" });
389
+ res.end(JSON.stringify({ error: "server is required" }));
390
+ return;
391
+ }
392
+ if (!this.config.servers[serverName]) {
393
+ // Mirror the /servers/:name miss shape (agent.ts:218-221) so the proxy
394
+ // can forward `available` straight into the LLM-facing error.
395
+ res.writeHead(404, { "Content-Type": "application/json" });
396
+ res.end(JSON.stringify({
397
+ error: `Unknown server: ${serverName}`,
398
+ available: Object.keys(this.config.servers),
399
+ }));
400
+ return;
401
+ }
402
+ const killed = this.restartServer(serverName);
403
+ res.writeHead(200, { "Content-Type": "application/json" });
404
+ res.end(JSON.stringify({ ok: true, killed }));
405
+ }
406
+ // Destroy + drop every live session belonging to `name`. killed=0 is
407
+ // success — the post-condition (no live session for this server) is met
408
+ // either way. McpSession.destroy synchronously fails pending requests
409
+ // with PROCESS_EXITED (so in-flight forwards resolve immediately rather
410
+ // than waiting on the per-request timeout) and tree-kills the whole
411
+ // process group (so `shell: true` / `npx` configs don't leave the real
412
+ // MCP server alive as a grandchild).
413
+ restartServer(name) {
414
+ let killed = 0;
415
+ for (const [id, session] of this.sessions) {
416
+ if (session.serverName !== name)
417
+ continue;
418
+ session.destroy();
419
+ this.sessions.delete(id);
420
+ killed++;
421
+ }
422
+ if (killed > 0)
423
+ console.log(`[${name}] Restarted: ${killed} session(s) destroyed`);
424
+ return killed;
425
+ }
313
426
  }
314
427
  exports.HostAgent = HostAgent;
@@ -1,7 +1,11 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.McpSession = void 0;
4
7
  const node_child_process_1 = require("node:child_process");
8
+ const tree_kill_1 = __importDefault(require("tree-kill"));
5
9
  const protocol_js_1 = require("../shared/protocol.js");
6
10
  const constants_js_1 = require("./constants.js");
7
11
  // One McpSession owns a single MCP server child process and matches its
@@ -193,12 +197,37 @@ class McpSession {
193
197
  get isAlive() {
194
198
  return !this.destroyed;
195
199
  }
200
+ // Idempotent. Three steps, in order:
201
+ //
202
+ // 1. Mark destroyed BEFORE failing pending so any synchronous
203
+ // sendRequest racing on another tick short-circuits to
204
+ // PROCESS_NOT_RUNNING instead of registering into a map we're
205
+ // about to drain.
206
+ // 2. failPending synchronously: don't wait for the child's 'exit'
207
+ // handler. A child that ignores SIGTERM (or a shell wrapper that
208
+ // swallows it) would otherwise leave callers waiting the full
209
+ // per-request timeout. The 'exit' handler's later failPending
210
+ // becomes a no-op against the now-empty map.
211
+ // 3. tree-kill the WHOLE process group, not just the direct child.
212
+ // Documented configs use `shell: true` / `npx`, where the real MCP
213
+ // server is a grandchild of /bin/sh; a bare process.kill() leaves
214
+ // it alive after the shell exits, which would silently break
215
+ // restart_server's contract ("session destroyed" but the wedged
216
+ // child is still answering on stdin somewhere).
196
217
  destroy() {
197
218
  if (this.destroyed)
198
219
  return;
199
220
  this.destroyed = true;
200
- if (!this.process.killed)
201
- this.process.kill();
221
+ this.failPending(protocol_js_1.ErrorCode.PROCESS_EXITED, "session destroyed");
222
+ if (!this.process.killed && this.process.pid !== undefined) {
223
+ // Default signal is SIGTERM; tree-kill walks ps/taskkill on
224
+ // POSIX/Windows and signals every descendant. Errors here mean the
225
+ // tree is already gone (race with natural exit) — best-effort.
226
+ (0, tree_kill_1.default)(this.process.pid, (err) => {
227
+ if (err)
228
+ console.error(`[${this.name}] tree-kill failed: ${err.message}`);
229
+ });
230
+ }
202
231
  }
203
232
  }
204
233
  exports.McpSession = McpSession;
@@ -6,8 +6,12 @@ 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;
12
14
  export declare const CONFIGURE_TOOL: Tool;
13
15
  export declare const CONFIGURE_PROMPT: Prompt;
16
+ export declare const RESTART_SERVER_TOOL: Tool;
17
+ export declare const RESTART_SERVER_PROMPT: Prompt;
@@ -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.RESTART_SERVER_PROMPT = exports.RESTART_SERVER_TOOL = 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
@@ -37,3 +44,41 @@ exports.CONFIGURE_PROMPT = {
37
44
  name: "configure",
38
45
  description: "Set up or reconfigure the MCP proxy connection.",
39
46
  };
47
+ // Local tool: kill the host's child process for a wedged MCP server so the
48
+ // next forward re-spawns it. The description teaches the LLM how to format
49
+ // the `tool` argument, since the form it sees in its catalog (wrapped by
50
+ // its MCP client as `mcp__<alias>__...`) is NOT the form the proxy parses.
51
+ exports.RESTART_SERVER_TOOL = {
52
+ name: "restart_server",
53
+ description: "Restart a wedged MCP server (kills the host's child process; next call respawns it). "
54
+ + "Pass `tool` as the proxy-internal name `<host>__<server>__<tool>`. "
55
+ + "If your tool catalog shows it as `mcp__<alias>__<host>__<server>__<tool>`, strip the `mcp__<alias>__` prefix first. "
56
+ + "Or pass `host` and `server` directly (visible in every tool's description as `[<host>/<server>]`).",
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ tool: {
61
+ type: "string",
62
+ description: "Proxy-internal tool name <host>__<server>__<tool>. Strip any mcp__<alias>__ wrapper your client adds.",
63
+ },
64
+ host: { type: "string", description: "Host id (alternative to tool)." },
65
+ server: { type: "string", description: "Server name (alternative to tool)." },
66
+ },
67
+ },
68
+ };
69
+ // Local prompt counterpart. Same handler, same parser — the prompt is just
70
+ // another entry point so an operator can drive a restart from the picker
71
+ // without round-tripping through the LLM's planner.
72
+ exports.RESTART_SERVER_PROMPT = {
73
+ name: "restart_server",
74
+ description: "Restart a wedged MCP server (kills the host's child; next call respawns).",
75
+ arguments: [
76
+ {
77
+ name: "tool",
78
+ description: "Proxy-internal tool name <host>__<server>__<tool>. Strip any mcp__<alias>__ wrapper your client adds.",
79
+ required: false,
80
+ },
81
+ { name: "host", description: "Host id (alternative to tool).", required: false },
82
+ { name: "server", description: "Server name (alternative to tool).", required: false },
83
+ ],
84
+ };
@@ -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 {
@@ -67,11 +67,12 @@ function validatePairingConfig(cfg) {
67
67
  // server-level allow list never triggered. Catching it here keeps the
68
68
  // invariant on the way IN to ProxyServer state.
69
69
  //
70
- // Server names may themselves contain `__`, so we don't `split` — we
71
- // take the prefix before the FIRST `__` as the hostId and require the
72
- // remainder (the serverName) to be non-empty so a malformed entry like
73
- // `host__` doesn't sneak through as a valid-looking allowlist line that
74
- // can never match any real server.
70
+ // Mirrors the indexOf form used by the selectedTools parser below for
71
+ // consistency. validateServerName forbids `__` in both the hostId and
72
+ // the serverName, so a single split on the FIRST `__` is sound; the
73
+ // remainder must be non-empty so a malformed entry like `host__`
74
+ // doesn't sneak through as a valid-looking allowlist line that can
75
+ // never match any real server.
75
76
  const validHostIds = new Set(validatedHosts.map((h) => h.id));
76
77
  if (cfg.selectedServers) {
77
78
  for (const entry of cfg.selectedServers) {
@@ -95,12 +96,13 @@ function validatePairingConfig(cfg) {
95
96
  // actually carry a tool name: an entry whose server prefix isn't in
96
97
  // selectedServers (when defined) — or whose hostId isn't a known host
97
98
  // (when selectedServers is omitted) — is unreachable noise that hides
98
- // bugs in the UI or stale configs. Tool/server names may themselves
99
- // contain `__`, so we prefix-match the allowed scope rather than
100
- // splitting on the separator. Shape is enforced separately by requiring
101
- // at least two `__` occurrences — the prefix-match alone degraded to a
102
- // hostId-only check when `selectedServers` was omitted, letting entries
103
- // like `host__bogus` survive without a tool segment.
99
+ // bugs in the UI or stale configs. validateServerName forbids `__` in
100
+ // host id and server name, but tool names CAN contain `__`, so we
101
+ // prefix-match the allowed scope rather than splitting on the separator.
102
+ // Shape is enforced separately by requiring at least two `__` occurrences
103
+ // — the prefix-match alone degraded to a hostId-only check when
104
+ // `selectedServers` was omitted, letting entries like `host__bogus`
105
+ // survive without a tool segment.
104
106
  if (cfg.selectedTools) {
105
107
  const allowedServerPrefixes = cfg.selectedServers
106
108
  ? cfg.selectedServers.map((s) => `${s}${constants_js_1.TOOL_SEPARATOR}`)
@@ -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, {
@@ -238,8 +253,9 @@ class PairingController {
238
253
  // to "every advertised host must land at least one server".
239
254
  const missing = [];
240
255
  if (cfg.selectedServers) {
241
- // Server names may contain `__`, so prefix-match on the FIRST `__`
242
- // — same parsing rule used in validatePairingConfig.
256
+ // validateServerName forbids `__` in host id and server name, so
257
+ // a single split on the FIRST `__` is sound — same parsing rule
258
+ // used in validatePairingConfig.
243
259
  for (const key of cfg.selectedServers) {
244
260
  const sep = key.indexOf("__");
245
261
  if (sep <= 0)
@@ -257,6 +273,22 @@ class PairingController {
257
273
  missing.push(`${host.config.id} (no servers)`);
258
274
  }
259
275
  }
276
+ // selectedTools entries are validated structurally by validatePairingConfig,
277
+ // but their existence on the wire can only be checked after discovery has
278
+ // populated state.toolRoute. The browser path can't produce stale entries
279
+ // (the UI builds selectedTools from the just-discovered set), but a direct
280
+ // caller of /pair/complete can — and getFilteredTools silently drops any
281
+ // entry whose key isn't in toolRoute, leaving the proxy paired-but-empty
282
+ // for tools with no error surfaced. Treat stale entries the same as
283
+ // missing servers: roll back to the previous pairing (or refuse, on
284
+ // first-time pair) so the operator gets a real failure signal instead of
285
+ // a cheerful ok on a config that won't expose any tools.
286
+ if (cfg.selectedTools) {
287
+ for (const key of cfg.selectedTools) {
288
+ if (!this.state.toolRoute.has(key))
289
+ missing.push(key);
290
+ }
291
+ }
260
292
  if (missing.length > 0) {
261
293
  // Cap the detail string so a wildly broken submit doesn't produce a
262
294
  // multi-kilobyte error body; the operator only needs a few names to
@@ -269,6 +301,12 @@ class PairingController {
269
301
  this.log(` New pairing missing servers (${detail}); restoring previous pairing`);
270
302
  this.state.installConfig(previousConfig, previousHosts);
271
303
  await this.runner.discoverServers();
304
+ // installConfig replaced toolRoute / promptRoute / resources between
305
+ // the success-path notify (only fires when missing.length === 0) and
306
+ // here, so an agent that polled tools/list during the new pairing's
307
+ // discovery may be holding a partial snapshot that no longer matches
308
+ // the restored routes. Notify list_changed so it re-fetches.
309
+ this.notifyAllListsChanged();
272
310
  return {
273
311
  ok: false,
274
312
  error: `Discovery did not complete for: ${detail}. The previous pairing has been restored — verify host reachability and retry.`,
@@ -279,6 +317,10 @@ class PairingController {
279
317
  // so the next configure call starts fresh).
280
318
  this.log(` New pairing missing servers (${detail}) and no prior config to restore; reverting to unconfigured`);
281
319
  this.state.config = null;
320
+ // Same notify rationale as the rollback branch above: a polling agent
321
+ // may have grabbed partial-new routes during discovery; tell it to
322
+ // re-fetch and find an empty unconfigured set.
323
+ this.notifyAllListsChanged();
282
324
  return {
283
325
  ok: false,
284
326
  error: `Discovery did not complete for: ${detail}. Verify host reachability and retry.`,
@@ -289,16 +331,27 @@ class PairingController {
289
331
  // Discovery already populated the aggregated lists. Notify the agent
290
332
  // so it re-fetches tools/prompts/resources instead of trusting any
291
333
  // 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");
334
+ this.notifyAllListsChanged();
295
335
  // Defer pairing teardown until /pair/complete's response body has
296
336
  // actually drained to the client — a fixed timer races slow clients
297
337
  // (mobile data, congested tunnel) and they see a dropped connection
298
338
  // on a successful pair.
299
339
  return { ok: true, afterFlush: () => this.teardownPairing() };
300
340
  }
341
+ notifyAllListsChanged() {
342
+ this.sendNotification("notifications/tools/list_changed");
343
+ this.sendNotification("notifications/prompts/list_changed");
344
+ this.sendNotification("notifications/resources/list_changed");
345
+ }
301
346
  async closeAllSessions() {
347
+ // Bump the supersession token before any await so an in-flight forwarder
348
+ // request that resolves during teardown sees a stale generation and
349
+ // refuses to write its response. Without this, a fetch completing during
350
+ // bridge.clear / DELETE awaits emits onto stdout against a pairing that
351
+ // no longer exists. installConfig() bumps again on the way in; the
352
+ // double-bump is harmless because the token is only used for equality
353
+ // comparison.
354
+ this.state.configGeneration++;
302
355
  // Tear down SSE listeners first so loops don't reconnect after DELETE.
303
356
  for (const host of this.state.hosts.values()) {
304
357
  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
  }