@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.
- package/dist/host/agent.d.ts +2 -0
- package/dist/host/agent.js +66 -0
- package/dist/host/session.js +31 -2
- package/dist/proxy/core/constants.d.ts +2 -0
- package/dist/proxy/core/constants.js +39 -1
- package/dist/proxy/pairing/config.js +13 -11
- package/dist/proxy/pairing/controller.js +3 -2
- package/dist/proxy/runtime/handlers.d.ts +3 -1
- package/dist/proxy/runtime/handlers.js +58 -7
- package/dist/proxy/runtime/restart.d.ts +17 -0
- package/dist/proxy/runtime/restart.js +119 -0
- package/dist/proxy/server.js +3 -1
- package/package.json +8 -2
package/dist/host/agent.d.ts
CHANGED
package/dist/host/agent.js
CHANGED
|
@@ -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;
|
package/dist/host/session.js
CHANGED
|
@@ -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
|
-
|
|
201
|
-
|
|
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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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.
|
|
99
|
-
// contain `__`, so we
|
|
100
|
-
//
|
|
101
|
-
// at least two `__` occurrences
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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
|
-
//
|
|
257
|
-
// — same parsing rule
|
|
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: [
|
|
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-
|
|
72
|
-
// regardless of upstream
|
|
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: [
|
|
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;
|
package/dist/proxy/server.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": {
|