@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,711 @@
1
+ // Pairing UI logic. Loaded by setup.html, talks to the proxy's pairing
2
+ // HTTP server (which is the same origin as this page — both are served by
3
+ // the proxy's ephemeral pairing tunnel, so no CORS dance). The bearer token
4
+ // rides in the URL fragment so it never appears in server access logs or
5
+ // Referer headers.
6
+ //
7
+ // Discovery is proxy-mediated: the page POSTs host credentials to
8
+ // /pair/list-servers and /pair/discover, and the proxy runs the same
9
+ // MCP handshake it will use at runtime — including the real client's
10
+ // captured capabilities/clientInfo. This is the single source of truth
11
+ // for what the user sees during setup vs. what the proxy will see at
12
+ // runtime; capability-gated upstreams cannot diverge between the two.
13
+
14
+ (function () {
15
+ 'use strict';
16
+
17
+ // Per-fetch budget for everything we send through /pair/*. The proxy
18
+ // applies its own per-upstream budget on top of this; this guard is
19
+ // about not pinning the page on a hung pairing tunnel.
20
+ const DISCOVERY_TIMEOUT_MS = 30000;
21
+ const TOOL_SEPARATOR = '__';
22
+
23
+ const hashParams = new URLSearchParams(location.hash.slice(1));
24
+ const pairingToken = hashParams.get('token');
25
+
26
+ if (!pairingToken) {
27
+ document.getElementById('step1').classList.remove('active');
28
+ document.getElementById('error-section').style.display = 'block';
29
+ throw new Error('Missing token');
30
+ }
31
+
32
+ // hosts: in-order list of { uid, id, tunnelUrl, authToken, servers, errors, capWarnings, status }
33
+ // uid is a transient DOM key; id is the user-facing host slug.
34
+ // servers is a map of name → { tools, prompts, resources, templates }.
35
+ // errors is a map of name → fatal discovery error string (server is
36
+ // unusable: tools/list failed, transport blew up, init handshake
37
+ // never completed). Drives the save-gate refusal.
38
+ // capWarnings is a map of name → optional-capability error string
39
+ // (prompts/list, resources/list, or resources/templates/list failed
40
+ // for an otherwise-healthy server). The runtime proxy retries these
41
+ // independently — surfacing them loudly so the user sees what is
42
+ // wrong, but NOT blocking save: blocking would refuse pairings the
43
+ // backend is explicitly designed to tolerate.
44
+ const hosts = [];
45
+ let hostUidCounter = 0;
46
+
47
+ // Reconfigure pre-fill: when /pair/info reports an existing config, we
48
+ // remember the prior server/tool allowlists here so renderServerTools()
49
+ // can restore the checkbox state after re-discovery. undefined entries
50
+ // mean "no prior pairing" → fall back to the default-everything-checked
51
+ // behaviour.
52
+ const priorSelections = { selectedServers: undefined, selectedTools: undefined };
53
+ // Host ids captured at bootstrap. priorSelections is only consulted for
54
+ // hosts whose id is in this set — so adding a new host, removing one,
55
+ // or renaming an id falls back to default-everything-checked for that
56
+ // host, while in-place reconfigure (only tunnelUrl/authToken changed)
57
+ // still inherits priors correctly.
58
+ const bootstrapHostIds = new Set();
59
+
60
+ function pushHost(initial) {
61
+ const uid = `h${++hostUidCounter}`;
62
+ hosts.push({
63
+ uid,
64
+ id: initial?.id || '',
65
+ tunnelUrl: initial?.tunnelUrl || '',
66
+ authToken: initial?.authToken || '',
67
+ servers: {},
68
+ errors: {},
69
+ capWarnings: {},
70
+ status: '',
71
+ });
72
+ return uid;
73
+ }
74
+
75
+ function newHostRow(initialId) {
76
+ pushHost({ id: initialId });
77
+ renderHostRows();
78
+ }
79
+
80
+ function removeHost(uid) {
81
+ const idx = hosts.findIndex((h) => h.uid === uid);
82
+ if (idx === -1) return;
83
+ hosts.splice(idx, 1);
84
+ if (hosts.length === 0) newHostRow('host-1');
85
+ else renderHostRows();
86
+ }
87
+
88
+ function renderHostRows() {
89
+ const container = document.getElementById('hosts-container');
90
+ container.innerHTML = '';
91
+ for (const host of hosts) {
92
+ const row = document.createElement('div');
93
+ row.className = 'host-row';
94
+ row.dataset.uid = host.uid;
95
+ row.innerHTML = `
96
+ <div class="host-head">
97
+ <div class="host-id" data-role="title">Host ${esc(host.id || '(unnamed)')}</div>
98
+ <button type="button" class="host-remove" data-action="remove">Remove</button>
99
+ </div>
100
+ <label for="id">Host ID</label>
101
+ <input id="id" type="text" data-field="id" value="${esc(host.id)}" placeholder="dev-laptop" required pattern="(?!.*__)[A-Za-z0-9._\\-]+" title="Letters, digits, '.', '_', '-'. Must not contain '__' and must be unique across hosts." />
102
+
103
+ <label for="tunnelUrl">Tunnel URL</label>
104
+ <input id="tunnelUrl" type="url" data-field="tunnelUrl" value="${esc(host.tunnelUrl)}" placeholder="https://abc-xyz.trycloudflare.com" required />
105
+
106
+ <label for="authToken">Auth Token</label>
107
+ <input id="authToken" type="text" data-field="authToken" value="${esc(host.authToken)}" placeholder="Paste token from host agent" required />
108
+
109
+ <div class="host-status ${host.status.startsWith('Error') ? 'error' : host.status.startsWith('Partial') ? 'partial' : host.status ? 'ok' : ''}">${esc(host.status)}</div>
110
+ `;
111
+ container.appendChild(row);
112
+ }
113
+ // Hide remove button when there's only one row.
114
+ const removeBtns = container.querySelectorAll('[data-action="remove"]');
115
+ if (removeBtns.length === 1) removeBtns[0].style.display = 'none';
116
+ // Newly-added rows have no setCustomValidity state and removing a row
117
+ // can resolve a duplicate flag on another; re-sweep so the UI is in
118
+ // sync with the current id values.
119
+ validateHostIdUniqueness();
120
+ }
121
+
122
+ document.getElementById('hosts-container').addEventListener('input', (e) => {
123
+ const row = e.target.closest('.host-row');
124
+ if (!row) return;
125
+ const host = hosts.find((h) => h.uid === row.dataset.uid);
126
+ if (!host) return;
127
+ const field = e.target.dataset.field;
128
+ if (!field) return;
129
+ host[field] = e.target.value;
130
+ if (field === 'id') {
131
+ const title = row.querySelector('[data-role="title"]');
132
+ if (title) title.textContent = `Host ${host.id || '(unnamed)'}`;
133
+ // Cross-field constraint: re-evaluate the duplicate-id rule on
134
+ // every keystroke. The pattern/required attributes already cover
135
+ // single-input rules; this is the one check that needs to look at
136
+ // its siblings, so we surface it through setCustomValidity rather
137
+ // than waiting for submit.
138
+ validateHostIdUniqueness();
139
+ }
140
+ });
141
+
142
+ // Walk every host-id input and flag duplicates with setCustomValidity.
143
+ // Calling setCustomValidity('') first clears any previous custom error
144
+ // without disturbing the native pattern/required validation, so a field
145
+ // that was duplicate but became unique falls back to its real validity
146
+ // state (which may still be invalid for other reasons). The :user-invalid
147
+ // CSS rule paints the border red uniformly across native and custom
148
+ // failures.
149
+ function validateHostIdUniqueness() {
150
+ const inputs = document.querySelectorAll('input[data-field="id"]');
151
+ const seen = new Map();
152
+ for (const input of inputs) input.setCustomValidity('');
153
+ for (const input of inputs) {
154
+ const v = input.value.trim();
155
+ if (!v) continue;
156
+ const prev = seen.get(v);
157
+ if (prev) {
158
+ const msg = `Host id "${v}" is already used by another host`;
159
+ input.setCustomValidity(msg);
160
+ prev.setCustomValidity(msg);
161
+ } else {
162
+ seen.set(v, input);
163
+ }
164
+ }
165
+ }
166
+
167
+ document.getElementById('hosts-container').addEventListener('click', (e) => {
168
+ const btn = e.target.closest('[data-action="remove"]');
169
+ if (!btn) return;
170
+ const row = btn.closest('.host-row');
171
+ if (row) removeHost(row.dataset.uid);
172
+ });
173
+
174
+ document.getElementById('add-host-btn').addEventListener('click', () => {
175
+ newHostRow(`host-${hosts.length + 1}`);
176
+ });
177
+
178
+ // Bootstrap: ask the proxy whether it's already configured. If so,
179
+ // pre-fill the host inputs and remember the prior selections so the
180
+ // user can tweak instead of retyping everything. We render a single
181
+ // empty row immediately so the page is interactive even if /pair/info
182
+ // is slow or fails — the prefill swaps the rows in once the response
183
+ // lands.
184
+ newHostRow('host-1');
185
+ bootstrap().catch((err) => {
186
+ console.warn('Could not load existing pairing config:', err);
187
+ });
188
+
189
+ async function bootstrap() {
190
+ const resp = await pairingFetch('/pair/info', { method: 'GET' });
191
+ if (!resp.ok) return;
192
+ const info = await resp.json();
193
+ const current = info && info.current;
194
+ if (!current || !Array.isArray(current.hosts) || current.hosts.length === 0) return;
195
+ hosts.length = 0;
196
+ bootstrapHostIds.clear();
197
+ for (const h of current.hosts) {
198
+ pushHost({ id: h.id, tunnelUrl: h.tunnelUrl, authToken: h.authToken });
199
+ bootstrapHostIds.add(h.id);
200
+ }
201
+ if (Array.isArray(current.selectedServers)) {
202
+ priorSelections.selectedServers = new Set(current.selectedServers);
203
+ }
204
+ if (Array.isArray(current.selectedTools)) {
205
+ priorSelections.selectedTools = new Set(current.selectedTools);
206
+ }
207
+ renderHostRows();
208
+ }
209
+
210
+ async function pairingFetch(path, init = {}) {
211
+ const headers = {
212
+ 'Authorization': `Bearer ${pairingToken}`,
213
+ 'Content-Type': 'application/json',
214
+ ...(init.headers || {}),
215
+ };
216
+ // Per-call timeout so a hung pairing tunnel can't lock up the page.
217
+ const signal = init.signal || AbortSignal.timeout(DISCOVERY_TIMEOUT_MS);
218
+ return fetch(path, { ...init, headers, signal });
219
+ }
220
+
221
+ async function pairPost(path, body) {
222
+ const resp = await pairingFetch(path, {
223
+ method: 'POST',
224
+ body: JSON.stringify(body),
225
+ });
226
+ let payload = null;
227
+ try {
228
+ payload = await resp.json();
229
+ } catch {
230
+ // Non-JSON body (transport-layer 502 from the pairing server, etc.).
231
+ }
232
+ return { resp, payload };
233
+ }
234
+
235
+ // --- Step 1: Discover servers across all configured hosts ---
236
+ document.getElementById('tunnel-form').addEventListener('submit', async (e) => {
237
+ e.preventDefault();
238
+ // Native form validation has already gated us — the browser blocks
239
+ // submit on any failing required/pattern/type=url constraint, and
240
+ // setCustomValidity wires the cross-field duplicate-id check into
241
+ // the same machinery. So by the time we get here every input is
242
+ // valid; we only need to normalise whitespace and run discovery.
243
+ const btn = document.getElementById('tunnel-btn');
244
+ btn.disabled = true;
245
+ btn.innerHTML = '<span class="spinner"></span> Discovering...';
246
+
247
+ for (const host of hosts) {
248
+ host.id = host.id.trim();
249
+ host.tunnelUrl = host.tunnelUrl.replace(/\/+$/, '');
250
+ host.authToken = host.authToken.trim();
251
+ }
252
+
253
+ // Parallel host discovery. discoverHost never throws — failures are
254
+ // recorded on host.status / host.errors so one bad host doesn't
255
+ // poison the batch. allSettled is defensive symmetry for the same
256
+ // reason.
257
+ await Promise.allSettled(hosts.map(discoverHost));
258
+ renderServerTools();
259
+ updateSaveState();
260
+
261
+ // Strict gate: every host must be reachable AND every server's
262
+ // tools/init must succeed. capWarnings (transient prompts/resources/
263
+ // templates failures) are intentionally NOT blocking — the runtime
264
+ // proxy retries them independently and the server stays online for
265
+ // tools regardless. They're surfaced loudly in the per-server banner
266
+ // so the user can see what was flaky, but they don't refuse the save
267
+ // gate the backend is explicitly designed to accept.
268
+ const failures = [];
269
+ for (const h of hosts) {
270
+ if (h.status.startsWith('Error')) {
271
+ failures.push(`${h.id}: ${h.status.replace(/^Error:\s*/, '')}`);
272
+ continue;
273
+ }
274
+ const serverNames = Object.keys(h.servers);
275
+ if (serverNames.length === 0) {
276
+ failures.push(`${h.id}: no servers exposed`);
277
+ continue;
278
+ }
279
+ for (const name of serverNames) {
280
+ if (h.errors[name]) failures.push(`${h.id}/${name}: ${h.errors[name]}`);
281
+ }
282
+ }
283
+ if (failures.length > 0) {
284
+ showError(`Fix these issues before continuing:\n• ${failures.join('\n• ')}`);
285
+ btn.disabled = false;
286
+ btn.textContent = 'Discover Servers';
287
+ return;
288
+ }
289
+ document.getElementById('step1').classList.remove('active');
290
+ document.getElementById('step2').classList.add('active');
291
+
292
+ btn.disabled = false;
293
+ btn.textContent = 'Discover Servers';
294
+ });
295
+
296
+ document.getElementById('back-btn').addEventListener('click', () => {
297
+ // Returning to step 1 keeps the host inputs (id/tunnelUrl/authToken)
298
+ // intact in the `hosts` array, so the user can correct one host's
299
+ // token without retyping the others. Discovered servers/errors are
300
+ // cleared because re-pressing "Discover" will repopulate them; we
301
+ // don't want stale error banners hanging around if the underlying
302
+ // host has since been fixed.
303
+ for (const h of hosts) {
304
+ h.servers = {};
305
+ h.errors = {};
306
+ h.capWarnings = {};
307
+ h.status = '';
308
+ }
309
+ document.getElementById('servers-container').innerHTML = '';
310
+ document.getElementById('step2').classList.remove('active');
311
+ document.getElementById('step1').classList.add('active');
312
+ renderHostRows();
313
+ });
314
+
315
+ async function discoverHost(host) {
316
+ host.servers = {};
317
+ host.errors = {};
318
+ host.capWarnings = {};
319
+ host.status = 'Discovering…';
320
+ renderHostRows();
321
+
322
+ // Step 1: list-servers. The proxy validates the tunnel URL allowlist
323
+ // and returns either the host's server names or a structured error
324
+ // (auth, transport, malformed body — distinguished server-side).
325
+ let listResult;
326
+ try {
327
+ const { resp, payload } = await pairPost('/pair/list-servers', {
328
+ tunnelUrl: host.tunnelUrl,
329
+ authToken: host.authToken,
330
+ });
331
+ if (!resp.ok || !payload || !payload.ok) {
332
+ const msg = (payload && payload.error)
333
+ || (resp.status === 401 ? 'invalid auth token' : `proxy returned ${resp.status}`);
334
+ host.status = `Error: ${msg}`;
335
+ renderHostRows();
336
+ return;
337
+ }
338
+ listResult = payload;
339
+ } catch (err) {
340
+ host.status = `Error: ${err.message || 'unreachable'}`;
341
+ renderHostRows();
342
+ return;
343
+ }
344
+
345
+ const serverNames = Array.isArray(listResult.servers) ? listResult.servers : [];
346
+ if (serverNames.length === 0) {
347
+ host.status = 'No servers exposed';
348
+ renderHostRows();
349
+ return;
350
+ }
351
+
352
+ // Step 2: per-server discovery. Servers within a host stay sequential
353
+ // — the proxy mediates each call, so parallelising here just shifts
354
+ // load onto the pairing HTTP server and the host's own MCP children
355
+ // without speeding the user-perceived flow. Each server is recorded
356
+ // independently so a single failure surfaces as a per-server error
357
+ // banner without taking the host status with it.
358
+ for (const name of serverNames) {
359
+ await discoverServer(host, name);
360
+ }
361
+ const counts = aggregateCounts(host);
362
+ // host.servers always carries an entry per advertised name (failed
363
+ // discovery leaves a placeholder so the UI still surfaces the row);
364
+ // host.errors is the canonical list of per-server FATAL failures
365
+ // (tools/init failed); host.capWarnings tracks non-fatal optional-
366
+ // capability errors. Status is derived from errors only — caps are
367
+ // displayed but don't change the host's headline state.
368
+ const totalCount = Object.keys(host.servers).length;
369
+ const errorCount = Object.keys(host.errors).length;
370
+ const warnCount = Object.keys(host.capWarnings).length;
371
+ const warnSuffix = warnCount > 0 ? ` (${warnCount} with cap warning${warnCount === 1 ? '' : 's'})` : '';
372
+ if (errorCount === 0) {
373
+ host.status = `OK — ${totalCount} server(s), ${counts.tools} tool(s)${warnSuffix}`;
374
+ } else if (errorCount === totalCount) {
375
+ host.status = `Error: ${errorCount} server(s) failed discovery`;
376
+ } else {
377
+ host.status = `Partial — ${totalCount - errorCount} ok, ${errorCount} failed${warnSuffix}`;
378
+ }
379
+ renderHostRows();
380
+ }
381
+
382
+ function aggregateCounts(host) {
383
+ let tools = 0, prompts = 0, resources = 0, templates = 0;
384
+ for (const s of Object.values(host.servers)) {
385
+ tools += s.tools.length;
386
+ prompts += s.prompts.length;
387
+ resources += s.resources.length;
388
+ templates += s.templates.length;
389
+ }
390
+ return { tools, prompts, resources, templates };
391
+ }
392
+
393
+ async function discoverServer(host, name) {
394
+ // Empty placeholder so the server still surfaces in the UI on
395
+ // failure (with its banner) rather than disappearing entirely.
396
+ host.servers[name] = { tools: [], prompts: [], resources: [], templates: [] };
397
+ try {
398
+ const { resp, payload } = await pairPost('/pair/discover', {
399
+ tunnelUrl: host.tunnelUrl,
400
+ authToken: host.authToken,
401
+ serverName: name,
402
+ });
403
+ if (!resp.ok || !payload || !payload.ok) {
404
+ // Fatal: tools/list or the init handshake failed. /pair/discover
405
+ // now mirrors /pair/list-servers and returns 401 with
406
+ // "invalid auth token" for upstream auth failures, so trust
407
+ // the server-supplied error before falling back to the status.
408
+ const msg = (payload && payload.error)
409
+ || (resp.status === 401 ? 'invalid auth token' : `proxy returned ${resp.status}`);
410
+ host.errors[name] = msg;
411
+ return;
412
+ }
413
+ host.servers[name] = {
414
+ tools: Array.isArray(payload.tools) ? payload.tools : [],
415
+ prompts: Array.isArray(payload.prompts) ? payload.prompts : [],
416
+ resources: Array.isArray(payload.resources) ? payload.resources : [],
417
+ templates: Array.isArray(payload.resourceTemplates) ? payload.resourceTemplates : [],
418
+ };
419
+ // Per-capability errors come back as a map. These are NON-FATAL —
420
+ // the runtime proxy retries each list independently and the
421
+ // server stays online for tools regardless. Mirror them onto
422
+ // host.capWarnings (separate from host.errors) so the user
423
+ // loudly sees which optional list failed without the save gate
424
+ // refusing the pairing.
425
+ if (payload.capErrors) {
426
+ const parts = [];
427
+ for (const k of ['prompts', 'resources', 'resourceTemplates']) {
428
+ if (payload.capErrors[k]) parts.push(`${k}: ${payload.capErrors[k]}`);
429
+ }
430
+ if (parts.length > 0) host.capWarnings[name] = parts.join('; ');
431
+ }
432
+ } catch (err) {
433
+ host.errors[name] = err.message || String(err);
434
+ }
435
+ }
436
+
437
+ function renderServerTools() {
438
+ const container = document.getElementById('servers-container');
439
+ container.innerHTML = '';
440
+
441
+ for (const host of hosts) {
442
+ const block = document.createElement('div');
443
+ block.className = 'host-block';
444
+ const head = document.createElement('div');
445
+ head.className = 'host-block-head';
446
+ head.textContent = `${host.id}`;
447
+ block.appendChild(head);
448
+
449
+ const serverNames = Object.keys(host.servers);
450
+ if (serverNames.length === 0) {
451
+ const hint = document.createElement('p');
452
+ hint.className = 'hint';
453
+ hint.textContent = 'No servers exposed';
454
+ block.appendChild(hint);
455
+ container.appendChild(block);
456
+ continue;
457
+ }
458
+
459
+ // priorSelections is only authoritative for hosts whose id existed
460
+ // at bootstrap. After the user adds, removes, or renames a host
461
+ // the saved allowlist no longer applies to that host — fall back
462
+ // to default-everything-checked instead of silently inheriting
463
+ // stale unchecked state.
464
+ const honorPriors = bootstrapHostIds.has(host.id);
465
+
466
+ for (const serverName of serverNames) {
467
+ const server = host.servers[serverName];
468
+ const error = host.errors[serverName];
469
+ const warning = host.capWarnings[serverName];
470
+ const group = document.createElement('div');
471
+ group.className = 'server-group';
472
+
473
+ const title = document.createElement('h3');
474
+ title.innerHTML = `<span class="scope">${esc(host.id)}/</span>${esc(serverName)}`;
475
+ group.appendChild(title);
476
+ const counts = document.createElement('div');
477
+ counts.className = 'server-counts';
478
+ counts.textContent = `${server.tools.length} tools, ${server.prompts.length} prompts, ${server.resources.length} resources, ${server.templates.length} templates`;
479
+ group.appendChild(counts);
480
+
481
+ // Server-level checkbox. Unchecked = the server is hidden
482
+ // completely: tools, prompts, resources, templates, and the
483
+ // routed methods that read them. Per-tool checkboxes act as
484
+ // a finer-grained filter ON TOP of this. Disabled only on
485
+ // FATAL errors — capWarnings (transient prompts/resources
486
+ // failures) are loud but non-blocking so the user can still
487
+ // pair a server whose tools succeeded. On a reconfigure for
488
+ // a host present at bootstrap, the prior allowlist wins so
489
+ // unchecked servers stay unchecked even if the host happens
490
+ // to discover new capabilities since last pairing.
491
+ const serverToggle = document.createElement('label');
492
+ serverToggle.className = 'server-toggle';
493
+ const serverCb = document.createElement('input');
494
+ serverCb.type = 'checkbox';
495
+ serverCb.dataset.role = 'server';
496
+ serverCb.dataset.host = host.id;
497
+ serverCb.dataset.server = serverName;
498
+ const hasAnything = server.tools.length + server.prompts.length + server.resources.length + server.templates.length > 0;
499
+ const serverKey = `${host.id}${TOOL_SEPARATOR}${serverName}`;
500
+ const priorServerChecked = honorPriors && priorSelections.selectedServers
501
+ ? priorSelections.selectedServers.has(serverKey)
502
+ : hasAnything;
503
+ serverCb.checked = !error && priorServerChecked;
504
+ serverCb.disabled = !!error;
505
+ serverCb.addEventListener('change', () => {
506
+ group.classList.toggle('disabled', !serverCb.checked);
507
+ updateSaveState();
508
+ });
509
+ const labelText = document.createElement('span');
510
+ labelText.textContent = 'Expose this server through the proxy';
511
+ serverToggle.appendChild(serverCb);
512
+ serverToggle.appendChild(labelText);
513
+ group.appendChild(serverToggle);
514
+
515
+ if (error) {
516
+ const banner = document.createElement('div');
517
+ banner.className = 'banner error';
518
+ banner.style.fontSize = '0.8rem';
519
+ banner.style.marginBottom = '0.5rem';
520
+ banner.textContent = `Discovery failed: ${error}`;
521
+ group.appendChild(banner);
522
+ }
523
+
524
+ // Loud-but-non-blocking warning for non-fatal capability
525
+ // failures. The runtime proxy will retry these on its own
526
+ // schedule; we surface them here so the user understands
527
+ // why a server has fewer prompts/resources than expected,
528
+ // without refusing to pair the server's tools.
529
+ if (warning && !error) {
530
+ const wbanner = document.createElement('div');
531
+ wbanner.className = 'banner warning';
532
+ wbanner.style.fontSize = '0.8rem';
533
+ wbanner.style.marginBottom = '0.5rem';
534
+ wbanner.textContent = `Capability list(s) failed (will retry at runtime): ${warning}`;
535
+ group.appendChild(wbanner);
536
+ }
537
+
538
+ if (server.tools.length > 0) {
539
+ group.appendChild(buildToolList(host.id, serverName, server.tools));
540
+ } else if (!error) {
541
+ const hint = document.createElement('p');
542
+ hint.className = 'hint';
543
+ hint.textContent = server.prompts.length + server.resources.length + server.templates.length > 0
544
+ ? 'No tools (prompts/resources only).'
545
+ : 'No exposable capabilities.';
546
+ group.appendChild(hint);
547
+ }
548
+
549
+ if (!serverCb.checked) group.classList.add('disabled');
550
+ block.appendChild(group);
551
+ }
552
+ container.appendChild(block);
553
+ }
554
+ }
555
+
556
+ function buildToolList(hostId, serverName, tools) {
557
+ // priorSelections is consulted only for hosts captured at bootstrap.
558
+ // For hosts the user added/renamed since, fall back to default-checked
559
+ // (same rule as the server-level checkbox).
560
+ const honorPriors = bootstrapHostIds.has(hostId);
561
+ const wrapper = document.createDocumentFragment();
562
+ const actions = document.createElement('div');
563
+ actions.className = 'select-actions';
564
+ const allBtn = document.createElement('button');
565
+ allBtn.type = 'button';
566
+ allBtn.textContent = 'Select all';
567
+ allBtn.addEventListener('click', () => toggleAllTools(hostId, serverName, true));
568
+ const noneBtn = document.createElement('button');
569
+ noneBtn.type = 'button';
570
+ noneBtn.textContent = 'Select none';
571
+ noneBtn.addEventListener('click', () => toggleAllTools(hostId, serverName, false));
572
+ actions.appendChild(allBtn);
573
+ actions.appendChild(noneBtn);
574
+ wrapper.appendChild(actions);
575
+
576
+ const list = document.createElement('div');
577
+ list.className = 'tool-list';
578
+ for (const tool of tools) {
579
+ const item = document.createElement('div');
580
+ item.className = 'tool-item';
581
+ const cbId = `tool-${hostId}-${serverName}-${tool.name}`;
582
+ const toolKey = `${hostId}${TOOL_SEPARATOR}${serverName}${TOOL_SEPARATOR}${tool.name}`;
583
+ const checked = honorPriors && priorSelections.selectedTools
584
+ ? priorSelections.selectedTools.has(toolKey)
585
+ : true;
586
+ item.innerHTML = `
587
+ <div class="tool-check">
588
+ <input type="checkbox" id="${esc(cbId)}" data-role="tool" data-host="${esc(hostId)}" data-server="${esc(serverName)}" data-tool="${esc(tool.name)}"${checked ? ' checked' : ''}>
589
+ </div>
590
+ <label class="tool-label" for="${esc(cbId)}">
591
+ <span class="tool-name">${esc(tool.name)}</span>
592
+ ${tool.description ? `<span class="tool-desc">${esc(tool.description)}</span>` : ''}
593
+ </label>
594
+ `;
595
+ list.appendChild(item);
596
+ }
597
+ wrapper.appendChild(list);
598
+ return wrapper;
599
+ }
600
+
601
+ function toggleAllTools(hostId, serverName, checked) {
602
+ const safeHost = CSS.escape(hostId);
603
+ const safeServer = CSS.escape(serverName);
604
+ document.querySelectorAll(`input[data-role="tool"][data-host="${safeHost}"][data-server="${safeServer}"]`).forEach((cb) => cb.checked = checked);
605
+ updateSaveState();
606
+ }
607
+
608
+ function updateSaveState() {
609
+ const serverChecked = document.querySelectorAll('input[data-role="server"]:checked').length;
610
+ const btn = document.getElementById('save-btn');
611
+ const hint = document.getElementById('save-hint');
612
+ btn.disabled = serverChecked === 0;
613
+ hint.textContent = serverChecked === 0
614
+ ? 'Select at least one server to continue.'
615
+ : `${serverChecked} server${serverChecked === 1 ? '' : 's'} selected.`;
616
+ }
617
+
618
+ document.getElementById('servers-container').addEventListener('change', (e) => {
619
+ if (e.target instanceof HTMLInputElement && e.target.dataset.role) updateSaveState();
620
+ });
621
+
622
+ document.getElementById('save-btn').addEventListener('click', saveConfig);
623
+
624
+ async function saveConfig() {
625
+ const btn = document.getElementById('save-btn');
626
+ btn.disabled = true;
627
+ btn.innerHTML = '<span class="spinner"></span> Saving...';
628
+
629
+ // Build the server-level allow list. Each entry is
630
+ // `<hostId>__<serverName>` — the same shape the runtime proxy
631
+ // expects in PairingConfig.selectedServers.
632
+ const selectedServers = [];
633
+ const allowedKeys = new Set();
634
+ document.querySelectorAll('input[data-role="server"]:checked').forEach((cb) => {
635
+ const key = `${cb.dataset.host}${TOOL_SEPARATOR}${cb.dataset.server}`;
636
+ selectedServers.push(key);
637
+ allowedKeys.add(key);
638
+ });
639
+
640
+ // Tool-level allow list, scoped to allowed servers. Tools whose
641
+ // server isn't in selectedServers are dropped on the way out so
642
+ // an unchecked server can't smuggle a tool through.
643
+ const selectedTools = [];
644
+ document.querySelectorAll('input[data-role="tool"]:checked').forEach((cb) => {
645
+ const serverKey = `${cb.dataset.host}${TOOL_SEPARATOR}${cb.dataset.server}`;
646
+ if (!allowedKeys.has(serverKey)) return;
647
+ selectedTools.push(`${serverKey}${TOOL_SEPARATOR}${cb.dataset.tool}`);
648
+ });
649
+
650
+ const config = {
651
+ hosts: hosts.map((h) => ({ id: h.id, tunnelUrl: h.tunnelUrl, authToken: h.authToken })),
652
+ selectedServers,
653
+ selectedTools,
654
+ sealed: true,
655
+ };
656
+
657
+ try {
658
+ const resp = await pairingFetch('/pair/complete', {
659
+ method: 'POST',
660
+ body: JSON.stringify(config),
661
+ });
662
+ const result = await resp.json().catch(() => ({}));
663
+
664
+ if (resp.ok && result.ok) {
665
+ showSuccess(config);
666
+ } else {
667
+ showError(result.error || `Failed to save (${resp.status})`);
668
+ }
669
+ } catch (err) {
670
+ showError(err.message);
671
+ }
672
+
673
+ btn.disabled = false;
674
+ btn.textContent = 'Complete Setup';
675
+ }
676
+
677
+ function esc(s) {
678
+ const d = document.createElement('div');
679
+ d.textContent = s == null ? '' : String(s);
680
+ return d.innerHTML;
681
+ }
682
+
683
+ function showSuccess(data) {
684
+ document.getElementById('step1').classList.remove('active');
685
+ document.getElementById('step2').classList.remove('active');
686
+ const r = document.getElementById('result-section');
687
+ r.style.display = 'block';
688
+ const hostList = data.hosts.map((h) => `${esc(h.id)} → <strong>${esc(h.tunnelUrl)}</strong>`).join('<br>');
689
+ r.innerHTML = `
690
+ <div class="banner ok">
691
+ Configuration applied!<br>
692
+ ${hostList}<br>
693
+ Servers: ${data.selectedServers.length} selected<br>
694
+ Tools: ${data.selectedTools.length} selected<br><br>
695
+ Return to your terminal — the proxy is now connected.
696
+ The pairing tunnel has been torn down.
697
+ </div>
698
+ `;
699
+ }
700
+
701
+ function showError(msg) {
702
+ const r = document.getElementById('result-section');
703
+ r.style.display = 'block';
704
+ // Preserve newlines so multi-line gating errors render as a list
705
+ // instead of one wall of text. esc() runs first so the original
706
+ // string can't smuggle markup; then we convert just the newlines.
707
+ const html = esc(msg).replace(/\n/g, '<br>');
708
+ r.innerHTML = `<div class="banner error">${html}</div>`;
709
+ setTimeout(() => { r.style.display = 'none'; }, 4000);
710
+ }
711
+ })();