@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.
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 -377
  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,130 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validatePairingConfig = validatePairingConfig;
4
+ const protocol_js_1 = require("../../shared/protocol.js");
5
+ const constants_js_1 = require("../core/constants.js");
6
+ const validation_js_1 = require("./validation.js");
7
+ // Validate a PairingConfig submitted by the setup page. Pure: returns the
8
+ // canonicalised host list (tunnelUrl reduced to its origin so query strings
9
+ // /paths can't smuggle through) or an error string. Centralised here rather
10
+ // than inlined in handleComplete so the rules are reviewable independently
11
+ // of the orchestrator. Every failure mode produces a human-readable error
12
+ // the setup page surfaces back to the user.
13
+ function validatePairingConfig(cfg) {
14
+ if (!cfg || cfg.sealed !== true)
15
+ return { ok: false, error: "config must be sealed" };
16
+ if (!Array.isArray(cfg.hosts) || cfg.hosts.length === 0) {
17
+ return { ok: false, error: "hosts must be a non-empty array" };
18
+ }
19
+ if (cfg.selectedServers !== undefined) {
20
+ if (!Array.isArray(cfg.selectedServers)) {
21
+ return { ok: false, error: "selectedServers must be an array if provided" };
22
+ }
23
+ // `undefined` means "allow all", which is a valid distinct shape. An
24
+ // explicit empty array would seal a config that exposes zero servers —
25
+ // exactly what the UI's "must select at least one" rule prevents on the
26
+ // client. Reject it here so a direct caller of /pair/complete can't
27
+ // bypass that gate and persist a paired-but-empty proxy.
28
+ if (cfg.selectedServers.length === 0) {
29
+ return { ok: false, error: "selectedServers must not be empty if provided" };
30
+ }
31
+ }
32
+ if (cfg.selectedTools !== undefined && !Array.isArray(cfg.selectedTools)) {
33
+ return { ok: false, error: "selectedTools must be an array if provided" };
34
+ }
35
+ const seen = new Set();
36
+ const validatedHosts = [];
37
+ for (const h of cfg.hosts) {
38
+ if (!h || typeof h !== "object")
39
+ return { ok: false, error: "each host must be an object" };
40
+ if (typeof h.id !== "string" || !h.id)
41
+ return { ok: false, error: "host.id is required" };
42
+ const reason = (0, protocol_js_1.validateServerName)(h.id);
43
+ if (reason)
44
+ return { ok: false, error: `host.id "${h.id}" ${reason}` };
45
+ if (seen.has(h.id))
46
+ return { ok: false, error: `duplicate host.id "${h.id}"` };
47
+ seen.add(h.id);
48
+ if (typeof h.tunnelUrl !== "string" || !h.tunnelUrl) {
49
+ return { ok: false, error: `host "${h.id}".tunnelUrl is required` };
50
+ }
51
+ if (typeof h.authToken !== "string" || !h.authToken) {
52
+ return { ok: false, error: `host "${h.id}".authToken is required` };
53
+ }
54
+ const validatedUrl = (0, validation_js_1.allowedTunnelUrl)(h.tunnelUrl);
55
+ if (!validatedUrl) {
56
+ return { ok: false, error: `host "${h.id}".tunnelUrl is not on the allowlist (must be https on a Cloudflare tunnel domain)` };
57
+ }
58
+ validatedHosts.push({
59
+ id: h.id,
60
+ tunnelUrl: validatedUrl.origin,
61
+ authToken: h.authToken,
62
+ ...(typeof h.label === "string" && h.label ? { label: h.label } : {}),
63
+ });
64
+ }
65
+ // selectedServers entries must reference hosts we actually know about,
66
+ // otherwise an attacker (or a stale UI) could shape the config so the
67
+ // server-level allow list never triggered. Catching it here keeps the
68
+ // invariant on the way IN to ProxyServer state.
69
+ //
70
+ // Server names may themselves contain `__`, so we don't `split` — we
71
+ // take the prefix before the FIRST `__` as the hostId and require the
72
+ // remainder (the serverName) to be non-empty so a malformed entry like
73
+ // `host__` doesn't sneak through as a valid-looking allowlist line that
74
+ // can never match any real server.
75
+ const validHostIds = new Set(validatedHosts.map((h) => h.id));
76
+ if (cfg.selectedServers) {
77
+ for (const entry of cfg.selectedServers) {
78
+ if (typeof entry !== "string") {
79
+ return { ok: false, error: `selectedServers entries must look like "<hostId>__<serverName>"` };
80
+ }
81
+ const sep = entry.indexOf(constants_js_1.TOOL_SEPARATOR);
82
+ if (sep <= 0 || sep + constants_js_1.TOOL_SEPARATOR.length >= entry.length) {
83
+ return { ok: false, error: `selectedServers entry "${entry}" must look like "<hostId>__<serverName>"` };
84
+ }
85
+ const hostId = entry.slice(0, sep);
86
+ if (!validHostIds.has(hostId)) {
87
+ return { ok: false, error: `selectedServers references unknown host "${hostId}"` };
88
+ }
89
+ }
90
+ }
91
+ // selectedTools entries are full prefixed tool names
92
+ // `<hostId>__<serverName>__<toolName>`. We can't validate <toolName>
93
+ // here (tools are discovered post-pair), but the prefix MUST point at
94
+ // an exposed server AND the entry must contain enough segments to
95
+ // actually carry a tool name: an entry whose server prefix isn't in
96
+ // selectedServers (when defined) — or whose hostId isn't a known host
97
+ // (when selectedServers is omitted) — is unreachable noise that hides
98
+ // bugs in the UI or stale configs. Tool/server names may themselves
99
+ // contain `__`, so we prefix-match the allowed scope rather than
100
+ // splitting on the separator. Shape is enforced separately by requiring
101
+ // at least two `__` occurrences — the prefix-match alone degraded to a
102
+ // hostId-only check when `selectedServers` was omitted, letting entries
103
+ // like `host__bogus` survive without a tool segment.
104
+ if (cfg.selectedTools) {
105
+ const allowedServerPrefixes = cfg.selectedServers
106
+ ? cfg.selectedServers.map((s) => `${s}${constants_js_1.TOOL_SEPARATOR}`)
107
+ : Array.from(validHostIds).map((id) => `${id}${constants_js_1.TOOL_SEPARATOR}`);
108
+ const scopeLabel = cfg.selectedServers ? "selectedServers" : "any known host";
109
+ for (const entry of cfg.selectedTools) {
110
+ if (typeof entry !== "string" || !entry) {
111
+ return { ok: false, error: "selectedTools entries must be non-empty strings" };
112
+ }
113
+ const firstSep = entry.indexOf(constants_js_1.TOOL_SEPARATOR);
114
+ const secondSep = firstSep < 0 ? -1 : entry.indexOf(constants_js_1.TOOL_SEPARATOR, firstSep + constants_js_1.TOOL_SEPARATOR.length);
115
+ if (firstSep < 0 || secondSep < 0) {
116
+ return { ok: false, error: `selectedTools entry "${entry}" must look like "<hostId>__<serverName>__<toolName>"` };
117
+ }
118
+ const matchedPrefix = allowedServerPrefixes.find((p) => entry.startsWith(p));
119
+ if (!matchedPrefix) {
120
+ return { ok: false, error: `selectedTools entry "${entry}" does not reference a server in ${scopeLabel}` };
121
+ }
122
+ // Must have at least one character after the matched server prefix
123
+ // — i.e. an actual tool segment, not the prefix on its own.
124
+ if (entry.length <= matchedPrefix.length) {
125
+ return { ok: false, error: `selectedTools entry "${entry}" is missing the tool name` };
126
+ }
127
+ }
128
+ }
129
+ return { ok: true, hosts: validatedHosts };
130
+ }
@@ -0,0 +1,19 @@
1
+ import type { ProxyState } from "../core/state.js";
2
+ import type { DiscoveryRunner } from "../discovery/runner.js";
3
+ import type { UpstreamBridge } from "../runtime/upstream-bridge.js";
4
+ export declare class PairingController {
5
+ private readonly state;
6
+ private readonly runner;
7
+ private readonly bridge;
8
+ private readonly log;
9
+ private readonly sendNotification;
10
+ private pairing;
11
+ constructor(state: ProxyState, runner: DiscoveryRunner, bridge: UpstreamBridge, log: (line: string) => void, sendNotification: (method: string) => void);
12
+ handleConfigure(): Promise<string>;
13
+ teardownPairing(): void;
14
+ private validateHostCreds;
15
+ private handleListServers;
16
+ private handleDiscover;
17
+ private handleComplete;
18
+ closeAllSessions(): Promise<void>;
19
+ }
@@ -0,0 +1,327 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PairingController = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const protocol_js_1 = require("../../shared/protocol.js");
6
+ const constants_js_1 = require("../core/constants.js");
7
+ const client_js_1 = require("../discovery/client.js");
8
+ const config_js_1 = require("./config.js");
9
+ const http_js_1 = require("./http.js");
10
+ const tunnel_js_1 = require("./tunnel.js");
11
+ const validation_js_1 = require("./validation.js");
12
+ // Owns the pairing flow end-to-end: brings up the temporary HTTP server +
13
+ // cloudflared tunnel, serves the setup page, mediates discovery on the
14
+ // browser's behalf (so pairing-time and runtime use the same client
15
+ // capabilities/clientInfo), validates the submitted config, and atomically
16
+ // installs it into ProxyState. Also closes any prior pairing's sessions
17
+ // before the next pairing's discovery starts.
18
+ class PairingController {
19
+ state;
20
+ runner;
21
+ bridge;
22
+ log;
23
+ sendNotification;
24
+ pairing = null;
25
+ constructor(state, runner, bridge, log, sendNotification) {
26
+ this.state = state;
27
+ this.runner = runner;
28
+ this.bridge = bridge;
29
+ this.log = log;
30
+ this.sendNotification = sendNotification;
31
+ }
32
+ async handleConfigure() {
33
+ this.teardownPairing();
34
+ const bearer = (0, node_crypto_1.randomBytes)(32).toString("base64url");
35
+ const http = new http_js_1.PairingHttpServer(bearer, {
36
+ listServers: (req) => this.handleListServers(req),
37
+ discover: (req) => this.handleDiscover(req),
38
+ complete: (cfg) => this.handleComplete(cfg),
39
+ // Surface the existing PairingConfig so a reconfigure flow can
40
+ // pre-fill the setup page. The endpoint is already gated by the
41
+ // pairing bearer token, so disclosing the auth tokens here is
42
+ // bounded by the same trust boundary as a fresh pairing.
43
+ info: () => ({
44
+ name: protocol_js_1.PACKAGE_NAME,
45
+ version: protocol_js_1.PACKAGE_VERSION,
46
+ ...(this.state.config ? {
47
+ current: {
48
+ hosts: this.state.config.hosts.map((h) => ({
49
+ id: h.id,
50
+ tunnelUrl: h.tunnelUrl,
51
+ authToken: h.authToken,
52
+ ...(h.label ? { label: h.label } : {}),
53
+ })),
54
+ ...(this.state.config.selectedServers ? { selectedServers: [...this.state.config.selectedServers] } : {}),
55
+ ...(this.state.config.selectedTools ? { selectedTools: [...this.state.config.selectedTools] } : {}),
56
+ },
57
+ } : {}),
58
+ }),
59
+ });
60
+ const port = await http.listen();
61
+ const tunnel = new tunnel_js_1.PairingTunnel();
62
+ let tunnelUrl;
63
+ try {
64
+ tunnelUrl = await tunnel.start(port, (reason) => {
65
+ // cloudflared/wrapper died after advertising a URL. The setup link
66
+ // we already returned points at a dead tunnel; tear pairing down so
67
+ // a subsequent `configure` call can re-bootstrap instead of waiting
68
+ // out PAIRING_WINDOW_MS. The reason carries whatever cloudflared
69
+ // last reported so the operator knows whether to retry or
70
+ // investigate.
71
+ this.log(` Pairing tunnel exited unexpectedly (${reason}); setup URL invalidated`);
72
+ this.teardownPairing();
73
+ });
74
+ }
75
+ catch (err) {
76
+ http.close();
77
+ tunnel.stop();
78
+ throw new Error(`Failed to start pairing tunnel: ${err.message}`);
79
+ }
80
+ // Setup page is served from the pairing tunnel itself — same origin as
81
+ // the /pair/* endpoints, so no CORS is involved. Token rides in the
82
+ // URL fragment so it never leaves the browser as Referer / origin log.
83
+ const setupUrl = `${tunnelUrl.replace(/\/+$/, "")}/#token=${bearer}`;
84
+ const expiryTimer = setTimeout(() => {
85
+ this.log(` Pairing window expired (${constants_js_1.PAIRING_WINDOW_MS / 1000}s); tunnel closed`);
86
+ this.teardownPairing();
87
+ }, constants_js_1.PAIRING_WINDOW_MS);
88
+ expiryTimer.unref();
89
+ this.pairing = { tunnel, http, setupUrl, expiryTimer };
90
+ this.log(`\n Configure at: ${setupUrl}\n`);
91
+ return `Open this URL in your browser to set up the MCP Proxy:\n\n${setupUrl}\n\nThe proxy will connect automatically once setup is complete.`;
92
+ }
93
+ teardownPairing() {
94
+ if (!this.pairing)
95
+ return;
96
+ const { tunnel, http, expiryTimer } = this.pairing;
97
+ this.pairing = null;
98
+ clearTimeout(expiryTimer);
99
+ try {
100
+ http.close();
101
+ }
102
+ catch { /* already closed */ }
103
+ try {
104
+ tunnel.stop();
105
+ }
106
+ catch { /* already stopped */ }
107
+ }
108
+ // Validate the host credentials the browser submitted on a /pair/* call.
109
+ // Single source of truth so list-servers and discover share the same
110
+ // gating rules — without this the two endpoints could disagree about
111
+ // what counts as a valid tunnel URL or what the canonicalised origin is.
112
+ validateHostCreds(req) {
113
+ if (!req.tunnelUrl || !req.authToken) {
114
+ return { ok: false, error: "tunnelUrl and authToken are required" };
115
+ }
116
+ const url = (0, validation_js_1.allowedTunnelUrl)(req.tunnelUrl);
117
+ if (!url) {
118
+ return {
119
+ ok: false,
120
+ error: "tunnelUrl is not on the allowlist. Use an https URL on a Cloudflare tunnel domain (.trycloudflare.com / .cfargotunnel.com), or extend MCP_TUNNEL_HOST_SUFFIXES / MCP_ALLOW_LOCAL on the proxy environment.",
121
+ };
122
+ }
123
+ return {
124
+ ok: true,
125
+ origin: url.origin,
126
+ headers: {
127
+ "Content-Type": "application/json",
128
+ Accept: "application/json, text/event-stream",
129
+ Authorization: `Bearer ${req.authToken}`,
130
+ },
131
+ };
132
+ }
133
+ // GET / on the host agent — list of advertised server names. Surfaces
134
+ // an explicit 401 status to the browser so the setup page can show
135
+ // "invalid auth token" rather than a generic upstream error.
136
+ async handleListServers(req) {
137
+ const v = this.validateHostCreds(req);
138
+ if (!v.ok)
139
+ return { ok: false, error: v.error, status: 400 };
140
+ try {
141
+ const servers = await (0, client_js_1.listHostServers)(v.origin, v.headers);
142
+ return { ok: true, servers };
143
+ }
144
+ catch (err) {
145
+ const msg = err.message;
146
+ if (msg.startsWith("list HTTP 401")) {
147
+ return { ok: false, error: "invalid auth token", status: 401 };
148
+ }
149
+ return { ok: false, error: msg };
150
+ }
151
+ }
152
+ // Per-server discovery on behalf of the setup page. Uses the captured
153
+ // clientCapabilities/clientInfo from the live MCP session — same values
154
+ // the runtime path will use — so capability-gated upstreams cannot look
155
+ // empty here and rich at runtime (or vice versa). Always closes the
156
+ // host-side session afterwards: pairing is read-only inspection, not
157
+ // an active session.
158
+ async handleDiscover(req) {
159
+ // Local validation failures must surface as HTTP 400, matching
160
+ // handleListServers — without `status` the http layer defaults to 502,
161
+ // which mislabels caller input errors as upstream failures and makes
162
+ // the two pairing endpoints disagree about the same class of problem.
163
+ const v = this.validateHostCreds(req);
164
+ if (!v.ok)
165
+ return { ok: false, error: v.error, status: 400 };
166
+ if (!req.serverName)
167
+ return { ok: false, error: "serverName is required", status: 400 };
168
+ const reason = (0, protocol_js_1.validateServerName)(req.serverName);
169
+ if (reason)
170
+ return { ok: false, error: `serverName ${reason}`, status: 400 };
171
+ const targetUrl = `${v.origin}/servers/${req.serverName}`;
172
+ let sessionId;
173
+ try {
174
+ const result = await (0, client_js_1.discoverServerCapabilities)(targetUrl, v.headers, req.serverName, this.state.clientCapabilities, this.state.clientInfo);
175
+ sessionId = result.sessionId;
176
+ const out = {
177
+ ok: true,
178
+ tools: result.tools,
179
+ prompts: result.prompts,
180
+ resources: result.resources,
181
+ resourceTemplates: result.resourceTemplates,
182
+ };
183
+ if (Object.keys(result.capErrors).length > 0)
184
+ out.capErrors = result.capErrors;
185
+ return out;
186
+ }
187
+ catch (err) {
188
+ const dErr = err;
189
+ sessionId = dErr.sessionId;
190
+ // Propagate auth failures the same way list-servers does so the UI
191
+ // can show "invalid auth token" instead of a generic 502. Discovery
192
+ // errors come from initialize / tools/list / prompts/list / etc and
193
+ // all spell their HTTP status as "<method> HTTP <status>: …".
194
+ if (/HTTP 401\b/.test(dErr.message ?? "")) {
195
+ return { ok: false, error: "invalid auth token", status: 401 };
196
+ }
197
+ return { ok: false, error: dErr.message };
198
+ }
199
+ finally {
200
+ if (sessionId) {
201
+ void (0, client_js_1.deleteSession)(targetUrl, { ...v.headers, "Mcp-Session-Id": sessionId });
202
+ }
203
+ }
204
+ }
205
+ async handleComplete(cfg) {
206
+ const validation = (0, config_js_1.validatePairingConfig)(cfg);
207
+ if (!validation.ok)
208
+ return { ok: false, error: validation.error };
209
+ // Snapshot the active pairing before tearing it down so we can roll
210
+ // back if the new pairing fails to land any server. closeAllSessions
211
+ // is unconditional, so without this snapshot a bad submit would leave
212
+ // the proxy paired-but-empty with no path back to the prior config.
213
+ const previousConfig = this.state.config;
214
+ const previousHosts = previousConfig?.hosts ?? null;
215
+ await this.closeAllSessions();
216
+ this.state.installConfig({
217
+ hosts: validation.hosts,
218
+ selectedServers: cfg.selectedServers,
219
+ selectedTools: cfg.selectedTools,
220
+ sealed: true,
221
+ }, validation.hosts);
222
+ // Wait for discovery to settle before responding so the operator
223
+ // gets a real success/failure signal instead of a cheerful ok on a
224
+ // paired-but-empty proxy. Discovery is bounded by per-host fetch
225
+ // timeouts; the browser is fine to wait that long.
226
+ await this.runner.discoverServers();
227
+ // Strict completion check — same rule the UI enforces at submit:
228
+ // every selectedServers entry must have ended up in state.hosts after
229
+ // discovery, otherwise we'd persist a partially broken pairing that
230
+ // the browser path would have rejected. Without this, a direct caller
231
+ // of the bearer-auth endpoint (UI gates client-side, but /pair/complete
232
+ // is callable directly) ends up paired with missing servers and the
233
+ // proxy reports `ok: true`. Mirror it server-side so behaviour is the
234
+ // same whichever path lands the config.
235
+ //
236
+ // selectedServers is optional in PairingConfig (allow-all). When it's
237
+ // omitted we can't enumerate which servers were "expected" — fall back
238
+ // to "every advertised host must land at least one server".
239
+ const missing = [];
240
+ if (cfg.selectedServers) {
241
+ // Server names may contain `__`, so prefix-match on the FIRST `__`
242
+ // — same parsing rule used in validatePairingConfig.
243
+ for (const key of cfg.selectedServers) {
244
+ const sep = key.indexOf("__");
245
+ if (sep <= 0)
246
+ continue; // shape already gated by validatePairingConfig
247
+ const hostId = key.slice(0, sep);
248
+ const serverName = key.slice(sep + 2);
249
+ if (!this.state.hosts.get(hostId)?.servers.has(serverName)) {
250
+ missing.push(key);
251
+ }
252
+ }
253
+ }
254
+ else {
255
+ for (const host of this.state.hosts.values()) {
256
+ if (host.servers.size === 0)
257
+ missing.push(`${host.config.id} (no servers)`);
258
+ }
259
+ }
260
+ if (missing.length > 0) {
261
+ // Cap the detail string so a wildly broken submit doesn't produce a
262
+ // multi-kilobyte error body; the operator only needs a few names to
263
+ // know which host to investigate.
264
+ const detail = missing.length > 5
265
+ ? `${missing.slice(0, 5).join(", ")}, and ${missing.length - 5} more`
266
+ : missing.join(", ");
267
+ await this.closeAllSessions();
268
+ if (previousConfig && previousHosts) {
269
+ this.log(` New pairing missing servers (${detail}); restoring previous pairing`);
270
+ this.state.installConfig(previousConfig, previousHosts);
271
+ await this.runner.discoverServers();
272
+ return {
273
+ ok: false,
274
+ error: `Discovery did not complete for: ${detail}. The previous pairing has been restored — verify host reachability and retry.`,
275
+ };
276
+ }
277
+ // First-time pairing missed servers: drop back to unconfigured
278
+ // (closeAllSessions cleared hosts/routes already; null the config
279
+ // so the next configure call starts fresh).
280
+ this.log(` New pairing missing servers (${detail}) and no prior config to restore; reverting to unconfigured`);
281
+ this.state.config = null;
282
+ return {
283
+ ok: false,
284
+ error: `Discovery did not complete for: ${detail}. Verify host reachability and retry.`,
285
+ };
286
+ }
287
+ const summary = validation.hosts.map((h) => `${h.id}=${h.tunnelUrl}`).join(", ");
288
+ this.log(` Paired! hosts: ${summary}`);
289
+ // Discovery already populated the aggregated lists. Notify the agent
290
+ // so it re-fetches tools/prompts/resources instead of trusting any
291
+ // cached empty lists from before pairing.
292
+ this.sendNotification("notifications/tools/list_changed");
293
+ this.sendNotification("notifications/prompts/list_changed");
294
+ this.sendNotification("notifications/resources/list_changed");
295
+ // Defer pairing teardown until /pair/complete's response body has
296
+ // actually drained to the client — a fixed timer races slow clients
297
+ // (mobile data, congested tunnel) and they see a dropped connection
298
+ // on a successful pair.
299
+ return { ok: true, afterFlush: () => this.teardownPairing() };
300
+ }
301
+ async closeAllSessions() {
302
+ // Tear down SSE listeners first so loops don't reconnect after DELETE.
303
+ for (const host of this.state.hosts.values()) {
304
+ for (const ctrl of host.sseControllers.values())
305
+ ctrl.abort();
306
+ host.sseControllers.clear();
307
+ }
308
+ this.state.inflight.clear();
309
+ this.state.progressTokens.clear();
310
+ // Drain bridged requests BEFORE the DELETEs below so each upstream
311
+ // child gets a JSON-RPC error answer. The DELETE will then reap the
312
+ // session, but the upstream side has already stopped waiting.
313
+ await this.bridge.clear(this.state.hosts);
314
+ const closes = [];
315
+ for (const host of this.state.hosts.values()) {
316
+ for (const [serverName, state] of host.servers) {
317
+ if (!state.sessionId)
318
+ continue;
319
+ const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": state.sessionId };
320
+ closes.push((0, client_js_1.deleteSession)(`${host.config.tunnelUrl}/servers/${serverName}`, headers));
321
+ }
322
+ }
323
+ await Promise.allSettled(closes);
324
+ this.state.resetAfterClose();
325
+ }
326
+ }
327
+ exports.PairingController = PairingController;
@@ -0,0 +1,70 @@
1
+ import type { PairingConfig, Prompt, Resource, ResourceTemplate, Tool } from "../core/types.js";
2
+ export interface ListServersRequest {
3
+ tunnelUrl: string;
4
+ authToken: string;
5
+ }
6
+ export interface ListServersResult {
7
+ ok: boolean;
8
+ servers?: string[];
9
+ error?: string;
10
+ status?: number;
11
+ }
12
+ export interface DiscoverServerRequest {
13
+ tunnelUrl: string;
14
+ authToken: string;
15
+ serverName: string;
16
+ }
17
+ export interface DiscoverServerResult {
18
+ ok: boolean;
19
+ tools?: Tool[];
20
+ prompts?: Prompt[];
21
+ resources?: Resource[];
22
+ resourceTemplates?: ResourceTemplate[];
23
+ capErrors?: {
24
+ prompts?: string;
25
+ resources?: string;
26
+ resourceTemplates?: string;
27
+ };
28
+ error?: string;
29
+ status?: number;
30
+ }
31
+ export type CompleteResult = {
32
+ ok: true;
33
+ afterFlush?: () => void;
34
+ } | {
35
+ ok: false;
36
+ error: string;
37
+ };
38
+ export interface PairingHandlers {
39
+ listServers: (req: ListServersRequest) => Promise<ListServersResult>;
40
+ discover: (req: DiscoverServerRequest) => Promise<DiscoverServerResult>;
41
+ complete: (cfg: PairingConfig) => Promise<CompleteResult>;
42
+ info: () => {
43
+ name: string;
44
+ version: string;
45
+ current?: {
46
+ hosts: Array<{
47
+ id: string;
48
+ tunnelUrl: string;
49
+ authToken: string;
50
+ label?: string;
51
+ }>;
52
+ selectedServers?: string[];
53
+ selectedTools?: string[];
54
+ };
55
+ };
56
+ }
57
+ export declare class PairingHttpServer {
58
+ private readonly bearer;
59
+ private readonly handlers;
60
+ private server;
61
+ private bearerExpected;
62
+ constructor(bearer: string, handlers: PairingHandlers);
63
+ listen(): Promise<number>;
64
+ close(): void;
65
+ private authorized;
66
+ private sendJson;
67
+ private sendStatic;
68
+ private readJson;
69
+ private handle;
70
+ }