@silver886/mcp-proxy 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -17
- package/dist/host/agent.d.ts +22 -0
- package/dist/host/agent.js +314 -0
- package/dist/host/cli.d.ts +1 -0
- package/dist/host/cli.js +83 -0
- package/dist/host/constants.d.ts +4 -0
- package/dist/host/constants.js +16 -0
- package/dist/host/session.d.ts +21 -0
- package/dist/host/session.js +204 -0
- package/dist/host/tunnel.d.ts +5 -0
- package/dist/host/tunnel.js +82 -0
- package/dist/host.js +8 -0
- package/dist/proxy/core/constants.d.ts +13 -0
- package/dist/proxy/core/constants.js +39 -0
- package/dist/proxy/core/fetch-timeout.d.ts +1 -0
- package/dist/proxy/core/fetch-timeout.js +15 -0
- package/dist/proxy/core/state.d.ts +25 -0
- package/dist/proxy/core/state.js +90 -0
- package/dist/proxy/core/types.d.ts +57 -0
- package/dist/proxy/core/types.js +5 -0
- package/dist/proxy/discovery/client.d.ts +42 -0
- package/dist/proxy/discovery/client.js +283 -0
- package/dist/proxy/discovery/runner.d.ts +21 -0
- package/dist/proxy/discovery/runner.js +319 -0
- package/dist/proxy/pairing/config.d.ts +9 -0
- package/dist/proxy/pairing/config.js +130 -0
- package/dist/proxy/pairing/controller.d.ts +19 -0
- package/dist/proxy/pairing/controller.js +327 -0
- package/dist/proxy/pairing/http.d.ts +70 -0
- package/dist/proxy/pairing/http.js +155 -0
- package/dist/proxy/pairing/static-assets.d.ts +4 -0
- package/dist/proxy/pairing/static-assets.js +13 -0
- package/dist/proxy/pairing/tunnel.d.ts +13 -0
- package/dist/proxy/pairing/tunnel.js +130 -0
- package/dist/proxy/pairing/validation.d.ts +2 -0
- package/dist/proxy/pairing/validation.js +62 -0
- package/dist/proxy/routing/filtering.d.ts +13 -0
- package/dist/proxy/routing/filtering.js +116 -0
- package/dist/proxy/routing/router.d.ts +17 -0
- package/dist/proxy/routing/router.js +74 -0
- package/dist/proxy/routing/uri.d.ts +7 -0
- package/dist/proxy/routing/uri.js +39 -0
- package/dist/proxy/runtime/forwarder.d.ts +15 -0
- package/dist/proxy/runtime/forwarder.js +265 -0
- package/dist/proxy/runtime/handlers.d.ts +48 -0
- package/dist/proxy/runtime/handlers.js +329 -0
- package/dist/proxy/runtime/sse.d.ts +19 -0
- package/dist/proxy/runtime/sse.js +169 -0
- package/dist/proxy/runtime/upstream-bridge.d.ts +27 -0
- package/dist/proxy/runtime/upstream-bridge.js +133 -0
- package/dist/proxy/server.d.ts +15 -0
- package/dist/proxy/server.js +167 -0
- package/dist/proxy.js +5 -0
- package/{mcp/dist → dist}/shared/protocol.d.ts +15 -3
- package/dist/shared/protocol.js +183 -0
- package/dist/wrapper.d.ts +2 -0
- package/dist/wrapper.js +72 -0
- package/package.json +15 -7
- package/static/setup.css +233 -0
- package/static/setup.html +57 -0
- package/static/setup.js +711 -0
- package/static/style.css +208 -0
- package/mcp/dist/host.js +0 -307
- package/mcp/dist/proxy.js +0 -377
- package/mcp/dist/shared/generated.d.ts +0 -2
- package/mcp/dist/shared/generated.js +0 -5
- package/mcp/dist/shared/protocol.js +0 -79
- /package/{mcp/dist → dist}/host.d.ts +0 -0
- /package/{mcp/dist → dist}/proxy.d.ts +0 -0
package/static/setup.js
ADDED
|
@@ -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
|
+
})();
|