@silver886/mcp-proxy 0.2.5 → 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.
@@ -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
  }
@@ -186,6 +186,15 @@ class HostAgent {
186
186
  }));
187
187
  return;
188
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
+ }
189
198
  const match = pathname.match(/^\/servers\/([^/]+)$/);
190
199
  if (!match) {
191
200
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -357,5 +366,62 @@ class HostAgent {
357
366
  }, constants_js_1.SSE_DRAIN_INTERVAL_MS);
358
367
  req.on("close", () => clearInterval(interval));
359
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
+ }
360
426
  }
361
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;
@@ -13,3 +13,5 @@ export declare const SSE_BACKOFF_MAX_MS = 10000;
13
13
  export declare const SSE_CONNECT_TIMEOUT_MS = 15000;
14
14
  export declare const CONFIGURE_TOOL: Tool;
15
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.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;
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.
@@ -44,3 +44,41 @@ exports.CONFIGURE_PROMPT = {
44
44
  name: "configure",
45
45
  description: "Set up or reconfigure the MCP proxy connection.",
46
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
+ };
@@ -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}`)
@@ -253,8 +253,9 @@ class PairingController {
253
253
  // to "every advertised host must land at least one server".
254
254
  const missing = [];
255
255
  if (cfg.selectedServers) {
256
- // Server names may contain `__`, so prefix-match on the FIRST `__`
257
- // — 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.
258
259
  for (const key of cfg.selectedServers) {
259
260
  const sep = key.indexOf("__");
260
261
  if (sep <= 0)
@@ -2,6 +2,7 @@ import type { ProxyState } from "../core/state.js";
2
2
  import type { DiscoveryRunner } from "../discovery/runner.js";
3
3
  import type { PairingController } from "../pairing/controller.js";
4
4
  import type { Forwarder } from "./forwarder.js";
5
+ import { type RestartHandler } from "./restart.js";
5
6
  import type { UpstreamBridge } from "./upstream-bridge.js";
6
7
  export declare class RequestHandlers {
7
8
  private readonly state;
@@ -9,9 +10,10 @@ export declare class RequestHandlers {
9
10
  private readonly forwarder;
10
11
  private readonly pairing;
11
12
  private readonly bridge;
13
+ private readonly restart;
12
14
  private readonly sendResult;
13
15
  private readonly sendError;
14
- constructor(state: ProxyState, runner: DiscoveryRunner, forwarder: Forwarder, pairing: PairingController, bridge: UpstreamBridge, sendResult: (id: string | number | null, result: unknown) => void, sendError: (code: number, detail: string | undefined, id: string | number | null) => void);
16
+ constructor(state: ProxyState, runner: DiscoveryRunner, forwarder: Forwarder, pairing: PairingController, bridge: UpstreamBridge, restart: RestartHandler, sendResult: (id: string | number | null, result: unknown) => void, sendError: (code: number, detail: string | undefined, id: string | number | null) => void);
15
17
  handleInitialize(id: string | number, params: {
16
18
  capabilities?: Record<string, unknown>;
17
19
  clientInfo?: {
@@ -5,6 +5,7 @@ const protocol_js_1 = require("../../shared/protocol.js");
5
5
  const constants_js_1 = require("../core/constants.js");
6
6
  const filtering_js_1 = require("../routing/filtering.js");
7
7
  const uri_js_1 = require("../routing/uri.js");
8
+ const restart_js_1 = require("./restart.js");
8
9
  // One method per JSON-RPC verb the agent can send. Each handler is
9
10
  // responsible for: parameter validation, looking up the route from the
10
11
  // (already discovered) state, and either answering locally or delegating
@@ -17,14 +18,16 @@ class RequestHandlers {
17
18
  forwarder;
18
19
  pairing;
19
20
  bridge;
21
+ restart;
20
22
  sendResult;
21
23
  sendError;
22
- constructor(state, runner, forwarder, pairing, bridge, sendResult, sendError) {
24
+ constructor(state, runner, forwarder, pairing, bridge, restart, sendResult, sendError) {
23
25
  this.state = state;
24
26
  this.runner = runner;
25
27
  this.forwarder = forwarder;
26
28
  this.pairing = pairing;
27
29
  this.bridge = bridge;
30
+ this.restart = restart;
28
31
  this.sendResult = sendResult;
29
32
  this.sendError = sendError;
30
33
  }
@@ -54,24 +57,33 @@ class RequestHandlers {
54
57
  }
55
58
  async handleToolsList(id) {
56
59
  if (!this.state.config) {
57
- this.sendResult(id, { tools: [constants_js_1.CONFIGURE_TOOL] });
60
+ this.sendResult(id, { tools: [constants_js_1.CONFIGURE_TOOL, constants_js_1.RESTART_SERVER_TOOL] });
58
61
  return;
59
62
  }
60
63
  await this.runner.retryDiscoveryIfNeeded();
61
64
  this.sendResult(id, {
62
- tools: [constants_js_1.CONFIGURE_TOOL, ...(0, filtering_js_1.getFilteredTools)(this.state.config, this.state.hosts, this.state.toolRoute)],
65
+ tools: [
66
+ constants_js_1.CONFIGURE_TOOL,
67
+ constants_js_1.RESTART_SERVER_TOOL,
68
+ ...(0, filtering_js_1.getFilteredTools)(this.state.config, this.state.hosts, this.state.toolRoute),
69
+ ],
63
70
  });
64
71
  }
65
72
  async handlePromptsList(id) {
66
73
  if (!this.state.config) {
67
- this.sendResult(id, { prompts: [constants_js_1.CONFIGURE_PROMPT] });
74
+ this.sendResult(id, { prompts: [constants_js_1.CONFIGURE_PROMPT, constants_js_1.RESTART_SERVER_PROMPT] });
68
75
  return;
69
76
  }
70
77
  await this.runner.retryDiscoveryIfNeeded();
71
- // Inject CONFIGURE_PROMPT first so re-pairing is always one prompt away
72
- // regardless of upstream state.
78
+ // Inject CONFIGURE_PROMPT and RESTART_SERVER_PROMPT first so re-pair
79
+ // and wedge-recovery are always one prompt away regardless of upstream
80
+ // state.
73
81
  this.sendResult(id, {
74
- prompts: [constants_js_1.CONFIGURE_PROMPT, ...(0, filtering_js_1.getAggregatedPrompts)(this.state.config, this.state.hosts, this.state.promptRoute)],
82
+ prompts: [
83
+ constants_js_1.CONFIGURE_PROMPT,
84
+ constants_js_1.RESTART_SERVER_PROMPT,
85
+ ...(0, filtering_js_1.getAggregatedPrompts)(this.state.config, this.state.hosts, this.state.promptRoute),
86
+ ],
75
87
  });
76
88
  }
77
89
  async handlePromptDispatch(id, params) {
@@ -97,6 +109,27 @@ class RequestHandlers {
97
109
  });
98
110
  return;
99
111
  }
112
+ if (promptName === "restart_server") {
113
+ let target;
114
+ try {
115
+ target = await this.restart.run(params?.arguments);
116
+ }
117
+ catch (err) {
118
+ if (err instanceof restart_js_1.RestartError) {
119
+ this.sendError(err.code, err.message, id);
120
+ return;
121
+ }
122
+ this.sendError(protocol_js_1.ErrorCode.INTERNAL, err.message, id);
123
+ return;
124
+ }
125
+ this.sendResult(id, {
126
+ messages: [
127
+ { role: "user", content: { type: "text", text: `Restart ${target.server} on ${target.host}.` } },
128
+ { role: "assistant", content: { type: "text", text: `Restarted ${target.server} on ${target.host}. Next call will respawn.` } },
129
+ ],
130
+ });
131
+ return;
132
+ }
100
133
  if (!promptName) {
101
134
  this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "name is required", id);
102
135
  return;
@@ -258,6 +291,24 @@ class RequestHandlers {
258
291
  this.sendResult(id, { content: [{ type: "text", text }] });
259
292
  return;
260
293
  }
294
+ if (toolName === "restart_server") {
295
+ let target;
296
+ try {
297
+ target = await this.restart.run(params.arguments);
298
+ }
299
+ catch (err) {
300
+ if (err instanceof restart_js_1.RestartError) {
301
+ this.sendError(err.code, err.message, id);
302
+ return;
303
+ }
304
+ this.sendError(protocol_js_1.ErrorCode.INTERNAL, err.message, id);
305
+ return;
306
+ }
307
+ this.sendResult(id, {
308
+ content: [{ type: "text", text: `Restarted ${target.server} on ${target.host}. Next call will respawn.` }],
309
+ });
310
+ return;
311
+ }
261
312
  if (!this.state.config) {
262
313
  this.sendError(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, "Call the `configure` tool first.", id);
263
314
  return;
@@ -0,0 +1,17 @@
1
+ import type { ProxyState } from "../core/state.js";
2
+ export declare class RestartError extends Error {
3
+ readonly code: number;
4
+ constructor(code: number, message: string);
5
+ }
6
+ export declare class RestartHandler {
7
+ private readonly state;
8
+ constructor(state: ProxyState);
9
+ parse(args: Record<string, unknown> | undefined): {
10
+ host: string;
11
+ server: string;
12
+ };
13
+ run(args: Record<string, unknown> | undefined): Promise<{
14
+ host: string;
15
+ server: string;
16
+ }>;
17
+ }
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RestartHandler = exports.RestartError = void 0;
4
+ const protocol_js_1 = require("../../shared/protocol.js");
5
+ const constants_js_1 = require("../core/constants.js");
6
+ const fetch_timeout_js_1 = require("../core/fetch-timeout.js");
7
+ // Typed error so the handlers layer can map a thrown failure straight to a
8
+ // JSON-RPC error code without a second taxonomy. Anything thrown out of
9
+ // RestartHandler.run carries one of ErrorCode.INVALID_PARAMS,
10
+ // HOST_UNREACHABLE, or INTERNAL.
11
+ class RestartError extends Error {
12
+ code;
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.code = code;
16
+ this.name = "RestartError";
17
+ }
18
+ }
19
+ exports.RestartError = RestartError;
20
+ // Owns the restart_server pipeline shared between the tool and prompt
21
+ // dispatch paths in RequestHandlers. Pure orchestrator — owns no state of
22
+ // its own; consults ProxyState for host resolution and POSTs the host's
23
+ // admin endpoint.
24
+ class RestartHandler {
25
+ state;
26
+ constructor(state) {
27
+ this.state = state;
28
+ }
29
+ // Parse the (tool? | host+server) input shape. Returns the resolved
30
+ // (host, server) pair or throws RestartError(INVALID_PARAMS) with a
31
+ // message the LLM can self-correct on.
32
+ parse(args) {
33
+ const tool = typeof args?.tool === "string" ? args.tool : undefined;
34
+ const host = typeof args?.host === "string" ? args.host : undefined;
35
+ const server = typeof args?.server === "string" ? args.server : undefined;
36
+ const hasTool = tool !== undefined && tool.length > 0;
37
+ const hasPair = host !== undefined && host.length > 0 && server !== undefined && server.length > 0;
38
+ const hasHalfPair = (host !== undefined && host.length > 0) !== (server !== undefined && server.length > 0);
39
+ if (hasTool && (host !== undefined || server !== undefined)) {
40
+ throw new RestartError(protocol_js_1.ErrorCode.INVALID_PARAMS, "exactly one of `tool` or `(host, server)` is required");
41
+ }
42
+ if (!hasTool && !hasPair) {
43
+ if (hasHalfPair) {
44
+ throw new RestartError(protocol_js_1.ErrorCode.INVALID_PARAMS, "both `host` and `server` are required when `tool` is not provided");
45
+ }
46
+ throw new RestartError(protocol_js_1.ErrorCode.INVALID_PARAMS, "exactly one of `tool` or `(host, server)` is required");
47
+ }
48
+ if (hasPair)
49
+ return { host: host, server: server };
50
+ // Parse `<host>__<server>[__<tool…>]`. host id and server name both
51
+ // pass validateServerName at ingress (forbids `__`), so two indexOf
52
+ // calls is sound — tool name segment may itself contain `__` and is
53
+ // discarded.
54
+ const sep = constants_js_1.TOOL_SEPARATOR;
55
+ const firstSep = tool.indexOf(sep);
56
+ if (firstSep < 0) {
57
+ throw new RestartError(protocol_js_1.ErrorCode.INVALID_PARAMS, `tool must be <host>__<server> or <host>__<server>__<tool>; if your client shows it as mcp__<alias>__..., strip that prefix first`);
58
+ }
59
+ const parsedHost = tool.slice(0, firstSep);
60
+ if (parsedHost.length === 0) {
61
+ throw new RestartError(protocol_js_1.ErrorCode.INVALID_PARAMS, "tool has empty host segment");
62
+ }
63
+ const afterHost = tool.slice(firstSep + sep.length);
64
+ const secondSep = afterHost.indexOf(sep);
65
+ const parsedServer = secondSep < 0 ? afterHost : afterHost.slice(0, secondSep);
66
+ if (parsedServer.length === 0) {
67
+ throw new RestartError(protocol_js_1.ErrorCode.INVALID_PARAMS, "tool has empty server segment");
68
+ }
69
+ return { host: parsedHost, server: parsedServer };
70
+ }
71
+ // Resolve, dispatch, return the resolved pair on success. The proxy does
72
+ // NOT mutate `server.sessionId`, `subscriptions`, or `sseControllers`
73
+ // here — the existing 404 recovery in forwarder.ts handles re-init
74
+ // naturally on the next forward, so all three triggers (idle GC, host
75
+ // restart, restart_server) flow through one path.
76
+ async run(args) {
77
+ const { host: hostId, server: serverName } = this.parse(args);
78
+ const host = this.state.hosts.get(hostId);
79
+ if (!host) {
80
+ const available = Array.from(this.state.hosts.keys()).join(", ") || "(none paired)";
81
+ throw new RestartError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown host: ${hostId} (available: ${available})`);
82
+ }
83
+ const url = `${host.config.tunnelUrl}/admin/restart`;
84
+ let resp;
85
+ try {
86
+ resp = await fetch(url, {
87
+ method: "POST",
88
+ headers: this.state.hostHeaders(host.config),
89
+ signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.DISCOVERY_FETCH_TIMEOUT_MS),
90
+ body: JSON.stringify({ server: serverName }),
91
+ });
92
+ }
93
+ catch (err) {
94
+ throw new RestartError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, `Host unreachable: ${err.message}`);
95
+ }
96
+ const text = await resp.text();
97
+ if (resp.status === 404) {
98
+ // Host returned the same shape as /servers/:name miss; surface its
99
+ // `available` list so the LLM can self-correct without another
100
+ // discovery round-trip.
101
+ let available = "(unknown)";
102
+ try {
103
+ const body = JSON.parse(text);
104
+ if (Array.isArray(body.available))
105
+ available = body.available.join(", ");
106
+ }
107
+ catch { /* fall through with placeholder */ }
108
+ throw new RestartError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown server: ${serverName} on ${hostId} (available: ${available})`);
109
+ }
110
+ if (resp.status === 401) {
111
+ throw new RestartError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, `Host rejected admin bearer (401) for ${hostId}`);
112
+ }
113
+ if (!resp.ok) {
114
+ throw new RestartError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, `Host returned ${resp.status} for ${hostId}: ${text.slice(0, 200)}`);
115
+ }
116
+ return { host: hostId, server: serverName };
117
+ }
118
+ }
119
+ exports.RestartHandler = RestartHandler;
@@ -8,6 +8,7 @@ const runner_js_1 = require("./discovery/runner.js");
8
8
  const controller_js_1 = require("./pairing/controller.js");
9
9
  const forwarder_js_1 = require("./runtime/forwarder.js");
10
10
  const handlers_js_1 = require("./runtime/handlers.js");
11
+ const restart_js_1 = require("./runtime/restart.js");
11
12
  const sse_js_1 = require("./runtime/sse.js");
12
13
  const upstream_bridge_js_1 = require("./runtime/upstream-bridge.js");
13
14
  // Composition root + JSON-RPC line router. Holds no business logic of
@@ -68,7 +69,8 @@ class ProxyServer {
68
69
  this.runner = new runner_js_1.DiscoveryRunner(this.state, this.sse, log);
69
70
  this.forwarder = new forwarder_js_1.Forwarder(this.state, this.runner, log, sendError, writeOut);
70
71
  this.pairing = new controller_js_1.PairingController(this.state, this.runner, this.bridge, log, sendNotification);
71
- this.handlers = new handlers_js_1.RequestHandlers(this.state, this.runner, this.forwarder, this.pairing, this.bridge, sendResult, sendError);
72
+ const restart = new restart_js_1.RestartHandler(this.state);
73
+ this.handlers = new handlers_js_1.RequestHandlers(this.state, this.runner, this.forwarder, this.pairing, this.bridge, restart, sendResult, sendError);
72
74
  }
73
75
  start() {
74
76
  const stdinBuffer = new protocol_js_1.LineBuffer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silver886/mcp-proxy",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "MCP proxy bridge: forward MCP requests across network boundaries via Cloudflare tunnel",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,7 +39,8 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "cloudflared": "^0.7.1",
42
- "eventsource-parser": "^3.0.8"
42
+ "eventsource-parser": "^3.0.8",
43
+ "tree-kill": "^1.2.2"
43
44
  },
44
45
  "devDependencies": {
45
46
  "@types/node": "^20.0.0",
@@ -56,6 +57,11 @@
56
57
  "version": "3.0.8",
57
58
  "tarball": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
58
59
  "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="
60
+ },
61
+ "tree-kill": {
62
+ "version": "1.2.2",
63
+ "tarball": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
64
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="
59
65
  }
60
66
  },
61
67
  "scripts": {