@silver886/mcp-proxy 0.1.4 → 0.2.0
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 +62 -17
- package/dist/host/agent.d.ts +22 -0
- package/dist/host/agent.js +314 -0
- package/dist/host/cli.d.ts +1 -0
- package/dist/host/cli.js +83 -0
- package/dist/host/constants.d.ts +4 -0
- package/dist/host/constants.js +16 -0
- package/dist/host/session.d.ts +21 -0
- package/dist/host/session.js +204 -0
- package/dist/host/tunnel.d.ts +5 -0
- package/dist/host/tunnel.js +82 -0
- package/dist/host.js +8 -0
- package/dist/proxy/core/constants.d.ts +13 -0
- package/dist/proxy/core/constants.js +39 -0
- package/dist/proxy/core/fetch-timeout.d.ts +1 -0
- package/dist/proxy/core/fetch-timeout.js +15 -0
- package/dist/proxy/core/state.d.ts +25 -0
- package/dist/proxy/core/state.js +90 -0
- package/dist/proxy/core/types.d.ts +57 -0
- package/dist/proxy/core/types.js +5 -0
- package/dist/proxy/discovery/client.d.ts +42 -0
- package/dist/proxy/discovery/client.js +283 -0
- package/dist/proxy/discovery/runner.d.ts +21 -0
- package/dist/proxy/discovery/runner.js +319 -0
- package/dist/proxy/pairing/config.d.ts +9 -0
- package/dist/proxy/pairing/config.js +130 -0
- package/dist/proxy/pairing/controller.d.ts +19 -0
- package/dist/proxy/pairing/controller.js +327 -0
- package/dist/proxy/pairing/http.d.ts +70 -0
- package/dist/proxy/pairing/http.js +155 -0
- package/dist/proxy/pairing/static-assets.d.ts +4 -0
- package/dist/proxy/pairing/static-assets.js +13 -0
- package/dist/proxy/pairing/tunnel.d.ts +13 -0
- package/dist/proxy/pairing/tunnel.js +130 -0
- package/dist/proxy/pairing/validation.d.ts +2 -0
- package/dist/proxy/pairing/validation.js +62 -0
- package/dist/proxy/routing/filtering.d.ts +13 -0
- package/dist/proxy/routing/filtering.js +116 -0
- package/dist/proxy/routing/router.d.ts +17 -0
- package/dist/proxy/routing/router.js +74 -0
- package/dist/proxy/routing/uri.d.ts +7 -0
- package/dist/proxy/routing/uri.js +39 -0
- package/dist/proxy/runtime/forwarder.d.ts +15 -0
- package/dist/proxy/runtime/forwarder.js +265 -0
- package/dist/proxy/runtime/handlers.d.ts +48 -0
- package/dist/proxy/runtime/handlers.js +329 -0
- package/dist/proxy/runtime/sse.d.ts +19 -0
- package/dist/proxy/runtime/sse.js +169 -0
- package/dist/proxy/runtime/upstream-bridge.d.ts +27 -0
- package/dist/proxy/runtime/upstream-bridge.js +133 -0
- package/dist/proxy/server.d.ts +15 -0
- package/dist/proxy/server.js +167 -0
- package/dist/proxy.js +5 -0
- package/{mcp/dist → dist}/shared/protocol.d.ts +15 -3
- package/dist/shared/protocol.js +183 -0
- package/dist/wrapper.d.ts +2 -0
- package/dist/wrapper.js +72 -0
- package/package.json +15 -7
- package/static/setup.css +233 -0
- package/static/setup.html +57 -0
- package/static/setup.js +711 -0
- package/static/style.css +208 -0
- package/mcp/dist/host.js +0 -307
- package/mcp/dist/proxy.js +0 -377
- package/mcp/dist/shared/generated.d.ts +0 -2
- package/mcp/dist/shared/generated.js +0 -5
- package/mcp/dist/shared/protocol.js +0 -79
- /package/{mcp/dist → dist}/host.d.ts +0 -0
- /package/{mcp/dist → dist}/proxy.d.ts +0 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DiscoveryError = void 0;
|
|
4
|
+
exports.initializeServer = initializeServer;
|
|
5
|
+
exports.sendInitialized = sendInitialized;
|
|
6
|
+
exports.fetchTools = fetchTools;
|
|
7
|
+
exports.fetchPromptsStrict = fetchPromptsStrict;
|
|
8
|
+
exports.fetchResourcesStrict = fetchResourcesStrict;
|
|
9
|
+
exports.fetchResourceTemplatesStrict = fetchResourceTemplatesStrict;
|
|
10
|
+
exports.discoverServerCapabilities = discoverServerCapabilities;
|
|
11
|
+
exports.deleteSession = deleteSession;
|
|
12
|
+
exports.listHostServers = listHostServers;
|
|
13
|
+
const protocol_js_1 = require("../../shared/protocol.js");
|
|
14
|
+
const constants_js_1 = require("../core/constants.js");
|
|
15
|
+
const fetch_timeout_js_1 = require("../core/fetch-timeout.js");
|
|
16
|
+
async function initializeServer(targetUrl, baseHeaders, name, clientCapabilities, clientInfo) {
|
|
17
|
+
const resp = await fetch(targetUrl, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: baseHeaders,
|
|
20
|
+
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.DISCOVERY_FETCH_TIMEOUT_MS),
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
jsonrpc: "2.0",
|
|
23
|
+
id: `init-${name}`,
|
|
24
|
+
method: "initialize",
|
|
25
|
+
params: {
|
|
26
|
+
protocolVersion: protocol_js_1.MCP_PROTOCOL_VERSION,
|
|
27
|
+
capabilities: clientCapabilities,
|
|
28
|
+
clientInfo,
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
ok: resp.ok,
|
|
34
|
+
status: resp.status,
|
|
35
|
+
sessionId: resp.headers.get("mcp-session-id") ?? undefined,
|
|
36
|
+
body: await resp.text(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function sendInitialized(targetUrl, sessionHeaders) {
|
|
40
|
+
await fetch(targetUrl, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: sessionHeaders,
|
|
43
|
+
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.DISCOVERY_FETCH_TIMEOUT_MS),
|
|
44
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
// Reject malformed envelopes (no `result` object, or expected field is not
|
|
48
|
+
// an array) with a thrown error rather than silently returning []. A broken
|
|
49
|
+
// upstream that answers a list call with `{}` or `{result: null}` would
|
|
50
|
+
// otherwise be indistinguishable from "feature absent" — the runtime proxy
|
|
51
|
+
// would mark the capability as healthy-but-empty, and the pairing UI would
|
|
52
|
+
// claim the server has zero tools/prompts/resources.
|
|
53
|
+
function extractListField(data, method, resultField) {
|
|
54
|
+
if (!data || typeof data !== "object") {
|
|
55
|
+
throw new Error(`${method} response is not a JSON object`);
|
|
56
|
+
}
|
|
57
|
+
const result = data.result;
|
|
58
|
+
if (!result || typeof result !== "object") {
|
|
59
|
+
throw new Error(`${method} response is missing the \`result\` object`);
|
|
60
|
+
}
|
|
61
|
+
const list = result[resultField];
|
|
62
|
+
if (!Array.isArray(list)) {
|
|
63
|
+
throw new Error(`${method} response is missing the \`result.${resultField}\` array`);
|
|
64
|
+
}
|
|
65
|
+
return list;
|
|
66
|
+
}
|
|
67
|
+
async function fetchTools(targetUrl, headers, name) {
|
|
68
|
+
const resp = await fetch(targetUrl, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers,
|
|
71
|
+
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.DISCOVERY_FETCH_TIMEOUT_MS),
|
|
72
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: `tools-${name}`, method: "tools/list", params: {} }),
|
|
73
|
+
});
|
|
74
|
+
if (!resp.ok)
|
|
75
|
+
throw new Error(`tools/list HTTP ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
|
|
76
|
+
let data;
|
|
77
|
+
try {
|
|
78
|
+
data = await resp.json();
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
throw new Error(`tools/list returned malformed JSON: ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
const error = data.error;
|
|
84
|
+
if (error)
|
|
85
|
+
throw new Error(`tools/list error: ${error.message ?? JSON.stringify(error)}`);
|
|
86
|
+
return extractListField(data, "tools/list", "tools");
|
|
87
|
+
}
|
|
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
|
+
// Run a tools/prompts/resources-style list call against the upstream and
|
|
93
|
+
// extract the list field. Throws on any transport, parse, or JSON-RPC
|
|
94
|
+
// failure other than METHOD_NOT_FOUND, which collapses to []. Callers
|
|
95
|
+
// (initial discovery, capability retry, refresh paths) catch and decide
|
|
96
|
+
// what to do — preserve cached state, mark pending, fail outright.
|
|
97
|
+
// Centralising the request/response shape here keeps the four list calls
|
|
98
|
+
// from drifting from each other.
|
|
99
|
+
async function fetchListStrict(targetUrl, headers, reqId, method, resultField) {
|
|
100
|
+
const resp = await fetch(targetUrl, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers,
|
|
103
|
+
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.DISCOVERY_FETCH_TIMEOUT_MS),
|
|
104
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: reqId, method, params: {} }),
|
|
105
|
+
});
|
|
106
|
+
if (!resp.ok)
|
|
107
|
+
throw new Error(`${method} HTTP ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
|
|
108
|
+
let data;
|
|
109
|
+
try {
|
|
110
|
+
data = await resp.json();
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
throw new Error(`${method} returned malformed JSON: ${err.message}`);
|
|
114
|
+
}
|
|
115
|
+
const error = data.error;
|
|
116
|
+
if (error) {
|
|
117
|
+
if (error.code === METHOD_NOT_FOUND)
|
|
118
|
+
return [];
|
|
119
|
+
throw new Error(`${method} error: ${error.message ?? JSON.stringify(error)}`);
|
|
120
|
+
}
|
|
121
|
+
return extractListField(data, method, resultField);
|
|
122
|
+
}
|
|
123
|
+
// Strict variants: throw on transport / non-METHOD_NOT_FOUND JSON-RPC
|
|
124
|
+
// errors so the caller can decide whether to preserve cached state. Used
|
|
125
|
+
// by refresh paths that must NOT wipe a server's prompts/resources on a
|
|
126
|
+
// transient blip.
|
|
127
|
+
function fetchPromptsStrict(targetUrl, headers, name) {
|
|
128
|
+
return fetchListStrict(targetUrl, headers, `prompts-${name}`, "prompts/list", "prompts");
|
|
129
|
+
}
|
|
130
|
+
function fetchResourcesStrict(targetUrl, headers, name) {
|
|
131
|
+
return fetchListStrict(targetUrl, headers, `resources-${name}`, "resources/list", "resources");
|
|
132
|
+
}
|
|
133
|
+
function fetchResourceTemplatesStrict(targetUrl, headers, name) {
|
|
134
|
+
return fetchListStrict(targetUrl, headers, `templates-${name}`, "resources/templates/list", "resourceTemplates");
|
|
135
|
+
}
|
|
136
|
+
// Errors thrown out of the handshake carry the captured session id, if
|
|
137
|
+
// any, so the caller can DELETE the orphaned upstream session after a
|
|
138
|
+
// post-init failure (sendInitialized / tools/list). Without this, a
|
|
139
|
+
// transient JSON-RPC error on tools/list would leak a child process on
|
|
140
|
+
// the host until idle GC reaped it ~30 minutes later.
|
|
141
|
+
class DiscoveryError extends Error {
|
|
142
|
+
sessionId;
|
|
143
|
+
constructor(message, sessionId) {
|
|
144
|
+
super(message);
|
|
145
|
+
this.sessionId = sessionId;
|
|
146
|
+
this.name = "DiscoveryError";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
exports.DiscoveryError = DiscoveryError;
|
|
150
|
+
// 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.
|
|
157
|
+
async function discoverServerCapabilities(targetUrl, baseHeaders, name, clientCapabilities, clientInfo, log) {
|
|
158
|
+
let sessionId;
|
|
159
|
+
try {
|
|
160
|
+
const init = await initializeServer(targetUrl, baseHeaders, name, clientCapabilities, clientInfo);
|
|
161
|
+
// Capture the session id BEFORE parsing the body. Once the host returned
|
|
162
|
+
// 200 with a session header it has minted a child process, so the catch
|
|
163
|
+
// path below needs the id to clean it up even if the JSON payload is an
|
|
164
|
+
// error or malformed.
|
|
165
|
+
sessionId = init.sessionId;
|
|
166
|
+
if (!init.ok) {
|
|
167
|
+
throw new Error(`initialize HTTP ${init.status}: ${init.body.slice(0, 200)}`);
|
|
168
|
+
}
|
|
169
|
+
let initData;
|
|
170
|
+
try {
|
|
171
|
+
initData = JSON.parse(init.body);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
throw new Error(`initialize returned malformed JSON: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
if (initData.error) {
|
|
177
|
+
throw new Error(`initialize error: ${initData.error.message ?? JSON.stringify(initData.error)}`);
|
|
178
|
+
}
|
|
179
|
+
const sessionHeaders = { ...baseHeaders };
|
|
180
|
+
if (sessionId)
|
|
181
|
+
sessionHeaders["Mcp-Session-Id"] = sessionId;
|
|
182
|
+
await sendInitialized(targetUrl, sessionHeaders);
|
|
183
|
+
const tools = await fetchTools(targetUrl, sessionHeaders, name);
|
|
184
|
+
// METHOD_NOT_FOUND on a capability is "feature absent" → empty list and
|
|
185
|
+
// no pending flag. Any other failure (transport, JSON-RPC error,
|
|
186
|
+
// malformed body) leaves the capability empty and sets the per-capability
|
|
187
|
+
// pending flag so the caller can either retry (runtime) or surface the
|
|
188
|
+
// failure to the user (pairing).
|
|
189
|
+
const [promptsResult, resourcesResult, templatesResult] = await Promise.allSettled([
|
|
190
|
+
fetchPromptsStrict(targetUrl, sessionHeaders, name),
|
|
191
|
+
fetchResourcesStrict(targetUrl, sessionHeaders, name),
|
|
192
|
+
fetchResourceTemplatesStrict(targetUrl, sessionHeaders, name),
|
|
193
|
+
]);
|
|
194
|
+
const capErrors = {};
|
|
195
|
+
const prompts = promptsResult.status === "fulfilled" ? promptsResult.value : [];
|
|
196
|
+
const pendingPrompts = promptsResult.status === "rejected";
|
|
197
|
+
if (pendingPrompts) {
|
|
198
|
+
capErrors.prompts = promptsResult.reason.message;
|
|
199
|
+
log?.(` [${name}] prompts/list failed (will retry): ${capErrors.prompts}`);
|
|
200
|
+
}
|
|
201
|
+
const resources = resourcesResult.status === "fulfilled" ? resourcesResult.value : [];
|
|
202
|
+
const pendingResources = resourcesResult.status === "rejected";
|
|
203
|
+
if (pendingResources) {
|
|
204
|
+
capErrors.resources = resourcesResult.reason.message;
|
|
205
|
+
log?.(` [${name}] resources/list failed (will retry): ${capErrors.resources}`);
|
|
206
|
+
}
|
|
207
|
+
const resourceTemplates = templatesResult.status === "fulfilled" ? templatesResult.value : [];
|
|
208
|
+
const pendingResourceTemplates = templatesResult.status === "rejected";
|
|
209
|
+
if (pendingResourceTemplates) {
|
|
210
|
+
capErrors.resourceTemplates = templatesResult.reason.message;
|
|
211
|
+
log?.(` [${name}] resources/templates/list failed (will retry): ${capErrors.resourceTemplates}`);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
sessionId,
|
|
215
|
+
tools,
|
|
216
|
+
prompts,
|
|
217
|
+
resources,
|
|
218
|
+
resourceTemplates,
|
|
219
|
+
pendingPrompts,
|
|
220
|
+
pendingResources,
|
|
221
|
+
pendingResourceTemplates,
|
|
222
|
+
capErrors,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
227
|
+
throw new DiscoveryError(message, sessionId);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function deleteSession(targetUrl, headers) {
|
|
231
|
+
try {
|
|
232
|
+
await fetch(targetUrl, {
|
|
233
|
+
method: "DELETE",
|
|
234
|
+
headers,
|
|
235
|
+
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.SESSION_DELETE_TIMEOUT_MS),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
/* host unreachable — idle GC will reap eventually */
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// List the servers a host advertises at GET /. Used by both runtime
|
|
243
|
+
// discovery (server.ts) and pairing-mediated discovery (the setup page,
|
|
244
|
+
// via the proxy's pairing endpoint). Filters server names through the
|
|
245
|
+
// shared validator so an upstream advertising a name the proxy/page
|
|
246
|
+
// can't safely route is dropped here rather than failing later in init —
|
|
247
|
+
// callers (runtime + pairing UI) get a single, consistent view of what
|
|
248
|
+
// the proxy will actually accept. Optional `log` surfaces dropped names
|
|
249
|
+
// to the operator on the runtime path; pairing leaves it unset so the
|
|
250
|
+
// UI just doesn't render unroutable rows.
|
|
251
|
+
async function listHostServers(hostUrl, headers, log) {
|
|
252
|
+
const resp = await fetch(`${hostUrl}/`, {
|
|
253
|
+
method: "GET",
|
|
254
|
+
headers,
|
|
255
|
+
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.DISCOVERY_FETCH_TIMEOUT_MS),
|
|
256
|
+
});
|
|
257
|
+
if (!resp.ok) {
|
|
258
|
+
throw new Error(`list HTTP ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
|
|
259
|
+
}
|
|
260
|
+
let data;
|
|
261
|
+
try {
|
|
262
|
+
data = await resp.json();
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
throw new Error(`list returned malformed JSON: ${err.message}`);
|
|
266
|
+
}
|
|
267
|
+
const servers = data.servers;
|
|
268
|
+
if (!Array.isArray(servers)) {
|
|
269
|
+
throw new Error("list response is missing the `servers` array");
|
|
270
|
+
}
|
|
271
|
+
const out = [];
|
|
272
|
+
for (const s of servers) {
|
|
273
|
+
if (typeof s !== "string")
|
|
274
|
+
continue;
|
|
275
|
+
const reason = (0, protocol_js_1.validateServerName)(s);
|
|
276
|
+
if (reason) {
|
|
277
|
+
log?.(` [${s}] skipped: ${reason}`);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
out.push(s);
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ProxyState } from "../core/state.js";
|
|
2
|
+
import type { HostState, ServerState } from "../core/types.js";
|
|
3
|
+
import type { SseReader } from "../runtime/sse.js";
|
|
4
|
+
export declare class DiscoveryRunner {
|
|
5
|
+
private readonly state;
|
|
6
|
+
private readonly sse;
|
|
7
|
+
private readonly log;
|
|
8
|
+
constructor(state: ProxyState, sse: SseReader, log: (line: string) => void);
|
|
9
|
+
captureSessionId(host: HostState, serverName: string, server: ServerState, newId: string | null): void;
|
|
10
|
+
private static hasPendingCapabilities;
|
|
11
|
+
retryDiscoveryIfNeeded(): Promise<void>;
|
|
12
|
+
discoverServers(): Promise<void>;
|
|
13
|
+
private runDiscovery;
|
|
14
|
+
private discoverHost;
|
|
15
|
+
private retryPendingCapabilities;
|
|
16
|
+
initServer(host: HostState, name: string): Promise<void>;
|
|
17
|
+
rebuildToolRoute(): void;
|
|
18
|
+
refreshTools(host: HostState, serverName: string): Promise<void>;
|
|
19
|
+
refreshPrompts(host: HostState, serverName: string): Promise<void>;
|
|
20
|
+
refreshResources(host: HostState, serverName: string): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DiscoveryRunner = void 0;
|
|
4
|
+
const constants_js_1 = require("../core/constants.js");
|
|
5
|
+
const filtering_js_1 = require("../routing/filtering.js");
|
|
6
|
+
const client_js_1 = require("./client.js");
|
|
7
|
+
// Owns the proxy's discovery + refresh + per-server init logic. One pass
|
|
8
|
+
// runs at a time (single-flighted via state.discoveryInflight); each
|
|
9
|
+
// individual server is initialised through the shared
|
|
10
|
+
// discoverServerCapabilities helper so pairing-time and runtime use the
|
|
11
|
+
// exact same handshake.
|
|
12
|
+
class DiscoveryRunner {
|
|
13
|
+
state;
|
|
14
|
+
sse;
|
|
15
|
+
log;
|
|
16
|
+
constructor(state, sse, log) {
|
|
17
|
+
this.state = state;
|
|
18
|
+
this.sse = sse;
|
|
19
|
+
this.log = log;
|
|
20
|
+
}
|
|
21
|
+
// After every upstream POST: if the host returned a different session
|
|
22
|
+
// id, restart the SSE notification loop bound to the new id. Without
|
|
23
|
+
// this, notifications go to the old session's queue and are silently
|
|
24
|
+
// lost. Lives here because it owns both the server state mutation and
|
|
25
|
+
// the SSE handle — Forwarder/Bridge call into it through a callback.
|
|
26
|
+
captureSessionId(host, serverName, server, newId) {
|
|
27
|
+
if (!newId)
|
|
28
|
+
return;
|
|
29
|
+
if (server.sessionId === newId)
|
|
30
|
+
return;
|
|
31
|
+
server.sessionId = newId;
|
|
32
|
+
this.sse.start(host, serverName, newId);
|
|
33
|
+
}
|
|
34
|
+
// True if any server stored under this host still has a capability list
|
|
35
|
+
// that needs to be re-fetched. Drives both the discovery host filter
|
|
36
|
+
// and retryDiscoveryIfNeeded's gate.
|
|
37
|
+
static hasPendingCapabilities(host) {
|
|
38
|
+
for (const server of host.servers.values()) {
|
|
39
|
+
if (server.pendingPrompts || server.pendingResources || server.pendingResourceTemplates)
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
// Retry-on-demand: kick another pass if any host hasn't fully settled.
|
|
45
|
+
// "Fully settled" means listing succeeded AND every server it named has
|
|
46
|
+
// an entry in host.servers (pendingServers is empty) AND every stored
|
|
47
|
+
// server has all three capability lists committed (no pending* flags).
|
|
48
|
+
// discoverServers is single-flighted, runDiscovery skips fully-settled
|
|
49
|
+
// hosts, discoverHost only re-runs init for the residual pendingServers,
|
|
50
|
+
// and retryPendingCapabilities only refetches the capability lists that
|
|
51
|
+
// are still pending — so this stays cheap once the system has stabilised.
|
|
52
|
+
async retryDiscoveryIfNeeded() {
|
|
53
|
+
if (!this.state.config)
|
|
54
|
+
return;
|
|
55
|
+
if (Array.from(this.state.hosts.values()).some((h) => !h.listed || h.pendingServers.size > 0 || DiscoveryRunner.hasPendingCapabilities(h))) {
|
|
56
|
+
await this.discoverServers();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
discoverServers() {
|
|
60
|
+
if (this.state.discoveryInflight)
|
|
61
|
+
return this.state.discoveryInflight;
|
|
62
|
+
if (!this.state.config)
|
|
63
|
+
return Promise.resolve();
|
|
64
|
+
const run = this.runDiscovery().finally(() => {
|
|
65
|
+
this.state.discoveryInflight = null;
|
|
66
|
+
});
|
|
67
|
+
this.state.discoveryInflight = run;
|
|
68
|
+
return run;
|
|
69
|
+
}
|
|
70
|
+
async runDiscovery() {
|
|
71
|
+
if (!this.state.config)
|
|
72
|
+
return;
|
|
73
|
+
// Snapshot the generation and the host references at the start. If a
|
|
74
|
+
// re-pair swaps in a new pairing while we're awaiting upstream calls,
|
|
75
|
+
// this run is superseded: discoverHost's writes go to detached host
|
|
76
|
+
// objects (harmless), and we skip rebuildToolRoute so we don't
|
|
77
|
+
// clobber the new pairing's state.
|
|
78
|
+
const gen = this.state.configGeneration;
|
|
79
|
+
const hostsSnapshot = Array.from(this.state.hosts.values());
|
|
80
|
+
// Parallel host discovery. One slow host no longer delays the others —
|
|
81
|
+
// each host's failures are caught inside discoverHost so a single
|
|
82
|
+
// rejected promise can't poison the batch (allSettled is still used
|
|
83
|
+
// for defensive symmetry). A host with listing done but lingering
|
|
84
|
+
// pendingServers gets re-entered so its residual inits retry.
|
|
85
|
+
await Promise.allSettled(hostsSnapshot
|
|
86
|
+
.filter((h) => !h.listed || h.pendingServers.size > 0 || DiscoveryRunner.hasPendingCapabilities(h))
|
|
87
|
+
.map((h) => this.discoverHost(h)));
|
|
88
|
+
if (gen !== this.state.configGeneration)
|
|
89
|
+
return;
|
|
90
|
+
this.rebuildToolRoute();
|
|
91
|
+
this.log(` Total tools: ${this.state.toolRoute.size}\n`);
|
|
92
|
+
}
|
|
93
|
+
async discoverHost(host) {
|
|
94
|
+
this.log(` Host [${host.config.id}] ${host.config.tunnelUrl}`);
|
|
95
|
+
if (!host.listed) {
|
|
96
|
+
let serverNames;
|
|
97
|
+
try {
|
|
98
|
+
// listHostServers itself runs each advertised name through the
|
|
99
|
+
// shared validator and logs anything dropped, so the result is
|
|
100
|
+
// already safe to route — no second filter needed here.
|
|
101
|
+
serverNames = await (0, client_js_1.listHostServers)(host.config.tunnelUrl, this.state.hostHeaders(host.config), this.log);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
this.log(` Discovery failed: ${err.message}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Drop deselected servers BEFORE we ever open a session. Without this
|
|
108
|
+
// the proxy spawns a child for every advertised server, forwards the
|
|
109
|
+
// real client capabilities upstream, and leaves an SSE loop attached
|
|
110
|
+
// — even for servers the user explicitly unchecked. selectedServers
|
|
111
|
+
// is a least-privilege boundary, so it has to gate side-effects, not
|
|
112
|
+
// just the agent-facing surface.
|
|
113
|
+
const selected = serverNames.filter((name) => {
|
|
114
|
+
if ((0, filtering_js_1.isServerSelected)(this.state.config, host.config.id, name))
|
|
115
|
+
return true;
|
|
116
|
+
this.log(` [${name}] skipped: not in selectedServers`);
|
|
117
|
+
return false;
|
|
118
|
+
});
|
|
119
|
+
this.log(` discovered: ${selected.join(", ") || "(none)"}`);
|
|
120
|
+
for (const name of selected)
|
|
121
|
+
host.pendingServers.add(name);
|
|
122
|
+
host.listed = true;
|
|
123
|
+
}
|
|
124
|
+
else if (host.pendingServers.size > 0) {
|
|
125
|
+
this.log(` retrying inits: ${Array.from(host.pendingServers).join(", ")}`);
|
|
126
|
+
}
|
|
127
|
+
// Snapshot first — initServer mutates pendingServers on success, and
|
|
128
|
+
// iterating a Set we're deleting from is footgun-territory. Servers
|
|
129
|
+
// within a host stay sequential: they share a session lifecycle and
|
|
130
|
+
// ordering keeps stderr readable.
|
|
131
|
+
for (const name of Array.from(host.pendingServers)) {
|
|
132
|
+
await this.initServer(host, name);
|
|
133
|
+
}
|
|
134
|
+
await this.retryPendingCapabilities(host);
|
|
135
|
+
}
|
|
136
|
+
// Re-fetch any capability list that failed during init for an
|
|
137
|
+
// already-stored server. Each per-capability flag is independent: a
|
|
138
|
+
// server with healthy tools but a transient prompts/list failure stays
|
|
139
|
+
// online and serves tools, and only the failed list is retried here.
|
|
140
|
+
// Strict variants keep the cached value on failure (preserve-on-failure)
|
|
141
|
+
// so a transient blip on the retry doesn't wipe what we already have.
|
|
142
|
+
async retryPendingCapabilities(host) {
|
|
143
|
+
for (const [serverName, server] of host.servers) {
|
|
144
|
+
if (!server.sessionId)
|
|
145
|
+
continue;
|
|
146
|
+
if (!server.pendingPrompts && !server.pendingResources && !server.pendingResourceTemplates)
|
|
147
|
+
continue;
|
|
148
|
+
const target = `${host.config.tunnelUrl}/servers/${serverName}`;
|
|
149
|
+
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": server.sessionId };
|
|
150
|
+
if (server.pendingPrompts) {
|
|
151
|
+
try {
|
|
152
|
+
server.prompts = await (0, client_js_1.fetchPromptsStrict)(target, headers, serverName);
|
|
153
|
+
server.pendingPrompts = false;
|
|
154
|
+
this.log(` [${host.config.id}/${serverName}] prompts retry ok: ${server.prompts.length}`);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
this.log(` [${host.config.id}/${serverName}] prompts retry failed: ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (server.pendingResources) {
|
|
161
|
+
try {
|
|
162
|
+
server.resources = await (0, client_js_1.fetchResourcesStrict)(target, headers, serverName);
|
|
163
|
+
server.pendingResources = false;
|
|
164
|
+
this.log(` [${host.config.id}/${serverName}] resources retry ok: ${server.resources.length}`);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
this.log(` [${host.config.id}/${serverName}] resources retry failed: ${err.message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (server.pendingResourceTemplates) {
|
|
171
|
+
try {
|
|
172
|
+
server.resourceTemplates = await (0, client_js_1.fetchResourceTemplatesStrict)(target, headers, serverName);
|
|
173
|
+
server.pendingResourceTemplates = false;
|
|
174
|
+
this.log(` [${host.config.id}/${serverName}] templates retry ok: ${server.resourceTemplates.length}`);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
this.log(` [${host.config.id}/${serverName}] templates retry failed: ${err.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async initServer(host, name) {
|
|
183
|
+
const targetUrl = `${host.config.tunnelUrl}/servers/${name}`;
|
|
184
|
+
const headers = this.state.hostHeaders(host.config);
|
|
185
|
+
let result;
|
|
186
|
+
try {
|
|
187
|
+
result = await (0, client_js_1.discoverServerCapabilities)(targetUrl, headers, name, this.state.clientCapabilities, this.state.clientInfo, (line) => this.log(line));
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
// Leave name in pendingServers so the next on-demand discovery pass
|
|
191
|
+
// retries the init. The list call already succeeded — only the per-
|
|
192
|
+
// server init is in residue. Best-effort cleanup of any orphaned
|
|
193
|
+
// upstream session the failed handshake left behind.
|
|
194
|
+
const dErr = err;
|
|
195
|
+
this.log(` [${name}] init failed: ${dErr.message}`);
|
|
196
|
+
if (dErr.sessionId) {
|
|
197
|
+
void (0, client_js_1.deleteSession)(targetUrl, { ...headers, "Mcp-Session-Id": dErr.sessionId });
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const state = {
|
|
202
|
+
sessionId: result.sessionId,
|
|
203
|
+
tools: result.tools,
|
|
204
|
+
prompts: result.prompts,
|
|
205
|
+
resources: result.resources,
|
|
206
|
+
resourceTemplates: result.resourceTemplates,
|
|
207
|
+
pendingPrompts: result.pendingPrompts,
|
|
208
|
+
pendingResources: result.pendingResources,
|
|
209
|
+
pendingResourceTemplates: result.pendingResourceTemplates,
|
|
210
|
+
// Fresh session starts with no subscriptions. Stale-session recovery
|
|
211
|
+
// in Forwarder snapshots the prior set BEFORE calling initServer and
|
|
212
|
+
// replays it onto the new state, so an init that runs as part of
|
|
213
|
+
// recovery still ends up with the right subscriptions populated.
|
|
214
|
+
subscriptions: new Set(),
|
|
215
|
+
};
|
|
216
|
+
host.servers.set(name, state);
|
|
217
|
+
host.pendingServers.delete(name);
|
|
218
|
+
if (result.sessionId)
|
|
219
|
+
this.sse.start(host, name, result.sessionId);
|
|
220
|
+
this.log(` [${name}] ${result.tools.length} tools, ${result.prompts.length} prompts, ${result.resources.length} resources, ${result.resourceTemplates.length} templates`);
|
|
221
|
+
}
|
|
222
|
+
rebuildToolRoute() {
|
|
223
|
+
this.state.toolRoute.clear();
|
|
224
|
+
this.state.promptRoute.clear();
|
|
225
|
+
this.state.resources.clear();
|
|
226
|
+
for (const host of this.state.hosts.values()) {
|
|
227
|
+
for (const [serverName, state] of host.servers) {
|
|
228
|
+
const route = (originalName) => ({ hostId: host.config.id, serverName, originalName });
|
|
229
|
+
for (const tool of state.tools) {
|
|
230
|
+
const prefixed = `${host.config.id}${constants_js_1.TOOL_SEPARATOR}${serverName}${constants_js_1.TOOL_SEPARATOR}${tool.name}`;
|
|
231
|
+
this.state.toolRoute.set(prefixed, route(tool.name));
|
|
232
|
+
}
|
|
233
|
+
for (const prompt of state.prompts) {
|
|
234
|
+
const prefixed = `${host.config.id}${constants_js_1.TOOL_SEPARATOR}${serverName}${constants_js_1.TOOL_SEPARATOR}${prompt.name}`;
|
|
235
|
+
this.state.promptRoute.set(prefixed, route(prompt.name));
|
|
236
|
+
}
|
|
237
|
+
const collisions = this.state.resources.add(host.config.id, serverName, state.resources, state.resourceTemplates);
|
|
238
|
+
for (const line of collisions)
|
|
239
|
+
this.log(` ${line}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
this.state.templateRoutes = this.state.resources.templateEntries();
|
|
243
|
+
}
|
|
244
|
+
// --- Refresh on list_changed ---
|
|
245
|
+
async refreshTools(host, serverName) {
|
|
246
|
+
const server = host.servers.get(serverName);
|
|
247
|
+
if (!server || !server.sessionId)
|
|
248
|
+
return;
|
|
249
|
+
const target = `${host.config.tunnelUrl}/servers/${serverName}`;
|
|
250
|
+
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": server.sessionId };
|
|
251
|
+
try {
|
|
252
|
+
server.tools = await (0, client_js_1.fetchTools)(target, headers, serverName);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.rebuildToolRoute();
|
|
258
|
+
this.log(` [${host.config.id}/${serverName}] tools refreshed: ${server.tools.length}`);
|
|
259
|
+
}
|
|
260
|
+
async refreshPrompts(host, serverName) {
|
|
261
|
+
const server = host.servers.get(serverName);
|
|
262
|
+
if (!server || !server.sessionId)
|
|
263
|
+
return;
|
|
264
|
+
const target = `${host.config.tunnelUrl}/servers/${serverName}`;
|
|
265
|
+
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": server.sessionId };
|
|
266
|
+
// Strict + preserve-on-failure: a transient HTTP/JSON-RPC blip during
|
|
267
|
+
// refresh used to wipe the cached prompt list and hide prompts until
|
|
268
|
+
// another list_changed arrived. Now we only commit the new list when
|
|
269
|
+
// the fetch actually succeeds.
|
|
270
|
+
let next;
|
|
271
|
+
try {
|
|
272
|
+
next = await (0, client_js_1.fetchPromptsStrict)(target, headers, serverName);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
this.log(` [${host.config.id}/${serverName}] prompts refresh failed (keeping cached ${server.prompts.length}): ${err.message}`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
server.prompts = next;
|
|
279
|
+
this.rebuildToolRoute();
|
|
280
|
+
this.log(` [${host.config.id}/${serverName}] prompts refreshed: ${server.prompts.length}`);
|
|
281
|
+
}
|
|
282
|
+
async refreshResources(host, serverName) {
|
|
283
|
+
const server = host.servers.get(serverName);
|
|
284
|
+
if (!server || !server.sessionId)
|
|
285
|
+
return;
|
|
286
|
+
const target = `${host.config.tunnelUrl}/servers/${serverName}`;
|
|
287
|
+
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": server.sessionId };
|
|
288
|
+
// Refresh both lists in parallel — the SSE notification only says
|
|
289
|
+
// "something changed", not whether it's the concrete list or the
|
|
290
|
+
// templates. At this scale a double-fetch is cheaper than waiting on
|
|
291
|
+
// a sequential chain. Each side is strict + preserve-on-failure so a
|
|
292
|
+
// transient failure on one list doesn't take the other down with it.
|
|
293
|
+
const [resourcesResult, templatesResult] = await Promise.allSettled([
|
|
294
|
+
(0, client_js_1.fetchResourcesStrict)(target, headers, serverName),
|
|
295
|
+
(0, client_js_1.fetchResourceTemplatesStrict)(target, headers, serverName),
|
|
296
|
+
]);
|
|
297
|
+
let resourcesChanged = false;
|
|
298
|
+
let templatesChanged = false;
|
|
299
|
+
if (resourcesResult.status === "fulfilled") {
|
|
300
|
+
server.resources = resourcesResult.value;
|
|
301
|
+
resourcesChanged = true;
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
this.log(` [${host.config.id}/${serverName}] resources refresh failed (keeping cached ${server.resources.length}): ${resourcesResult.reason.message}`);
|
|
305
|
+
}
|
|
306
|
+
if (templatesResult.status === "fulfilled") {
|
|
307
|
+
server.resourceTemplates = templatesResult.value;
|
|
308
|
+
templatesChanged = true;
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
this.log(` [${host.config.id}/${serverName}] templates refresh failed (keeping cached ${server.resourceTemplates.length}): ${templatesResult.reason.message}`);
|
|
312
|
+
}
|
|
313
|
+
if (!resourcesChanged && !templatesChanged)
|
|
314
|
+
return;
|
|
315
|
+
this.rebuildToolRoute();
|
|
316
|
+
this.log(` [${host.config.id}/${serverName}] resources refreshed: ${server.resources.length} concrete, ${server.resourceTemplates.length} templates`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
exports.DiscoveryRunner = DiscoveryRunner;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { HostConfig, PairingConfig } from "../core/types.js";
|
|
2
|
+
export type PairingConfigValidation = {
|
|
3
|
+
ok: true;
|
|
4
|
+
hosts: HostConfig[];
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
error: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function validatePairingConfig(cfg: PairingConfig): PairingConfigValidation;
|