@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 +2 -1
- package/dist/host/agent.d.ts +2 -0
- package/dist/host/agent.js +135 -22
- package/dist/host/session.js +31 -2
- package/dist/proxy/core/constants.d.ts +4 -0
- package/dist/proxy/core/constants.js +46 -1
- package/dist/proxy/discovery/client.js +24 -11
- package/dist/proxy/pairing/config.js +13 -11
- package/dist/proxy/pairing/controller.d.ts +3 -0
- package/dist/proxy/pairing/controller.js +59 -6
- package/dist/proxy/pairing/http.js +11 -0
- package/dist/proxy/runtime/forwarder.js +22 -0
- package/dist/proxy/runtime/handlers.d.ts +5 -3
- package/dist/proxy/runtime/handlers.js +68 -12
- package/dist/proxy/runtime/restart.d.ts +17 -0
- package/dist/proxy/runtime/restart.js +119 -0
- package/dist/proxy/runtime/sse.js +6 -0
- package/dist/proxy/runtime/upstream-bridge.js +9 -3
- package/dist/proxy/server.js +13 -1
- package/dist/shared/protocol.d.ts +7 -0
- package/dist/shared/protocol.js +44 -0
- package/package.json +10 -3
- package/static/setup.js +86 -25
|
@@ -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 {
|
|
@@ -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?: {
|
|
@@ -40,9 +42,9 @@ export declare class RequestHandlers {
|
|
|
40
42
|
argument?: unknown;
|
|
41
43
|
}): Promise<void>;
|
|
42
44
|
handleToolDispatch(id: string | number, params: {
|
|
43
|
-
name
|
|
45
|
+
name?: string;
|
|
44
46
|
arguments?: Record<string, unknown>;
|
|
45
47
|
_meta?: unknown;
|
|
46
|
-
}): Promise<void>;
|
|
48
|
+
} | undefined): Promise<void>;
|
|
47
49
|
handleClientNotification(method: string, params: Record<string, unknown>): Promise<void>;
|
|
48
50
|
}
|
|
@@ -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;
|
|
@@ -241,7 +274,12 @@ class RequestHandlers {
|
|
|
241
274
|
});
|
|
242
275
|
}
|
|
243
276
|
async handleToolDispatch(id, params) {
|
|
244
|
-
if (params.name
|
|
277
|
+
if (!params || typeof params.name !== "string") {
|
|
278
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "name is required", id);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const toolName = params.name;
|
|
282
|
+
if (toolName === "configure") {
|
|
245
283
|
let text;
|
|
246
284
|
try {
|
|
247
285
|
text = await this.pairing.handleConfigure();
|
|
@@ -253,18 +291,36 @@ class RequestHandlers {
|
|
|
253
291
|
this.sendResult(id, { content: [{ type: "text", text }] });
|
|
254
292
|
return;
|
|
255
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
|
+
}
|
|
256
312
|
if (!this.state.config) {
|
|
257
313
|
this.sendError(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, "Call the `configure` tool first.", id);
|
|
258
314
|
return;
|
|
259
315
|
}
|
|
260
|
-
const route = this.state.toolRoute.get(
|
|
316
|
+
const route = this.state.toolRoute.get(toolName);
|
|
261
317
|
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: ${
|
|
318
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${toolName}`, id);
|
|
263
319
|
return;
|
|
264
320
|
}
|
|
265
321
|
// 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(
|
|
267
|
-
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${
|
|
322
|
+
if (this.state.config.selectedTools !== undefined && !this.state.config.selectedTools.includes(toolName)) {
|
|
323
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${toolName}`, id);
|
|
268
324
|
return;
|
|
269
325
|
}
|
|
270
326
|
// Preserve `_meta` so the upstream server still sees the agent's
|
|
@@ -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;
|
|
@@ -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)(
|
|
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"));
|
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();
|
|
@@ -120,6 +122,16 @@ class ProxyServer {
|
|
|
120
122
|
process.stdout.write((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.INVALID_REQUEST, "missing method", parsed.id ?? null) + "\n");
|
|
121
123
|
return;
|
|
122
124
|
}
|
|
125
|
+
// JSON-RPC 2.0 discourages id:null in requests because the spec
|
|
126
|
+
// reserves null for "id couldn't be parsed" in error responses
|
|
127
|
+
// (we use it ourselves at the PARSE_ERROR / missing-method paths).
|
|
128
|
+
// Falling through to the notification branch here would silently
|
|
129
|
+
// drop a request the agent expects an answer to; reject explicitly
|
|
130
|
+
// so the agent sees a real error instead of hanging.
|
|
131
|
+
if (parsed.id === null) {
|
|
132
|
+
process.stdout.write((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.INVALID_REQUEST, "id must not be null in a request", null) + "\n");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
123
135
|
if (!hasId) {
|
|
124
136
|
await this.handlers.handleClientNotification(parsed.method, parsed.params ?? {});
|
|
125
137
|
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;
|
package/dist/shared/protocol.js
CHANGED
|
@@ -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
|
+
"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,9 +57,15 @@
|
|
|
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": {
|
|
62
|
-
"build": "tsc"
|
|
68
|
+
"build": "tsc",
|
|
69
|
+
"test": "tsc --noEmit"
|
|
63
70
|
}
|
|
64
71
|
}
|