@silver886/mcp-proxy 0.1.3 → 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.
Files changed (69) hide show
  1. package/README.md +62 -17
  2. package/dist/host/agent.d.ts +22 -0
  3. package/dist/host/agent.js +314 -0
  4. package/dist/host/cli.d.ts +1 -0
  5. package/dist/host/cli.js +83 -0
  6. package/dist/host/constants.d.ts +4 -0
  7. package/dist/host/constants.js +16 -0
  8. package/dist/host/session.d.ts +21 -0
  9. package/dist/host/session.js +204 -0
  10. package/dist/host/tunnel.d.ts +5 -0
  11. package/dist/host/tunnel.js +82 -0
  12. package/dist/host.js +8 -0
  13. package/dist/proxy/core/constants.d.ts +13 -0
  14. package/dist/proxy/core/constants.js +39 -0
  15. package/dist/proxy/core/fetch-timeout.d.ts +1 -0
  16. package/dist/proxy/core/fetch-timeout.js +15 -0
  17. package/dist/proxy/core/state.d.ts +25 -0
  18. package/dist/proxy/core/state.js +90 -0
  19. package/dist/proxy/core/types.d.ts +57 -0
  20. package/dist/proxy/core/types.js +5 -0
  21. package/dist/proxy/discovery/client.d.ts +42 -0
  22. package/dist/proxy/discovery/client.js +283 -0
  23. package/dist/proxy/discovery/runner.d.ts +21 -0
  24. package/dist/proxy/discovery/runner.js +319 -0
  25. package/dist/proxy/pairing/config.d.ts +9 -0
  26. package/dist/proxy/pairing/config.js +130 -0
  27. package/dist/proxy/pairing/controller.d.ts +19 -0
  28. package/dist/proxy/pairing/controller.js +327 -0
  29. package/dist/proxy/pairing/http.d.ts +70 -0
  30. package/dist/proxy/pairing/http.js +155 -0
  31. package/dist/proxy/pairing/static-assets.d.ts +4 -0
  32. package/dist/proxy/pairing/static-assets.js +13 -0
  33. package/dist/proxy/pairing/tunnel.d.ts +13 -0
  34. package/dist/proxy/pairing/tunnel.js +130 -0
  35. package/dist/proxy/pairing/validation.d.ts +2 -0
  36. package/dist/proxy/pairing/validation.js +62 -0
  37. package/dist/proxy/routing/filtering.d.ts +13 -0
  38. package/dist/proxy/routing/filtering.js +116 -0
  39. package/dist/proxy/routing/router.d.ts +17 -0
  40. package/dist/proxy/routing/router.js +74 -0
  41. package/dist/proxy/routing/uri.d.ts +7 -0
  42. package/dist/proxy/routing/uri.js +39 -0
  43. package/dist/proxy/runtime/forwarder.d.ts +15 -0
  44. package/dist/proxy/runtime/forwarder.js +265 -0
  45. package/dist/proxy/runtime/handlers.d.ts +48 -0
  46. package/dist/proxy/runtime/handlers.js +329 -0
  47. package/dist/proxy/runtime/sse.d.ts +19 -0
  48. package/dist/proxy/runtime/sse.js +169 -0
  49. package/dist/proxy/runtime/upstream-bridge.d.ts +27 -0
  50. package/dist/proxy/runtime/upstream-bridge.js +133 -0
  51. package/dist/proxy/server.d.ts +15 -0
  52. package/dist/proxy/server.js +167 -0
  53. package/dist/proxy.js +5 -0
  54. package/{mcp/dist → dist}/shared/protocol.d.ts +15 -3
  55. package/dist/shared/protocol.js +183 -0
  56. package/dist/wrapper.d.ts +2 -0
  57. package/dist/wrapper.js +72 -0
  58. package/package.json +15 -7
  59. package/static/setup.css +233 -0
  60. package/static/setup.html +57 -0
  61. package/static/setup.js +711 -0
  62. package/static/style.css +208 -0
  63. package/mcp/dist/host.js +0 -307
  64. package/mcp/dist/proxy.js +0 -374
  65. package/mcp/dist/shared/generated.d.ts +0 -2
  66. package/mcp/dist/shared/generated.js +0 -5
  67. package/mcp/dist/shared/protocol.js +0 -79
  68. /package/{mcp/dist → dist}/host.d.ts +0 -0
  69. /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;