toolfunnel 0.7.0 → 0.8.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 (73) hide show
  1. package/README.md +238 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +530 -451
  4. package/docs/KNOWN_BUGS.md +100 -80
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -84
  7. package/docs/packaging.md +116 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -39
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +379 -380
  14. package/package.json +67 -67
  15. package/src/auth/config.js +172 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +358 -353
  18. package/src/core/atomic-write.js +97 -0
  19. package/src/core/child-reaper.js +61 -61
  20. package/src/core/config-home.js +226 -134
  21. package/src/core/events.js +105 -105
  22. package/src/core/hook-engine.js +304 -329
  23. package/src/core/hook-loader.js +766 -824
  24. package/src/core/hook-runner.js +615 -566
  25. package/src/core/logger.js +295 -297
  26. package/src/core/matcher.js +100 -100
  27. package/src/core/metrics.js +90 -75
  28. package/src/core/output-cap.js +31 -0
  29. package/src/core/rename-migration.js +178 -163
  30. package/src/core/requires.js +138 -138
  31. package/src/core/server-config.js +98 -98
  32. package/src/extend/add-hook.md +155 -152
  33. package/src/extend/add-mcp.md +119 -119
  34. package/src/extend/configure.md +88 -88
  35. package/src/extend/create-tool.md +168 -168
  36. package/src/extend/howto.js +102 -102
  37. package/src/extend/package.md +190 -190
  38. package/src/extend/wrap.md +99 -99
  39. package/src/mcp/aggregator.js +2248 -1674
  40. package/src/mcp/expose-store.js +755 -577
  41. package/src/mcp/gated-run.js +210 -210
  42. package/src/mcp/http-transport.js +1657 -1563
  43. package/src/mcp/mcp-client.js +1174 -1070
  44. package/src/mcp/modern.js +409 -406
  45. package/src/mcp/protocol.js +473 -468
  46. package/src/mcp/server.js +3317 -3112
  47. package/src/mcp/tool-approvals.js +396 -0
  48. package/src/tools/registry.js +607 -661
  49. package/src/tools/tool-state.js +170 -170
  50. package/src/ui/public/app.js +1246 -1187
  51. package/src/ui/public/index.html +286 -286
  52. package/src/ui/public/styles.css +535 -535
  53. package/src/ui/server.js +1742 -1616
  54. package/tools/scripts/base64.js +139 -139
  55. package/tools/scripts/danger.js +61 -61
  56. package/tools/scripts/echo.js +39 -39
  57. package/tools/scripts/hash.js +101 -101
  58. package/tools/scripts/json.js +109 -109
  59. package/tools/scripts/text-stats.js +131 -131
  60. package/tools/scripts/tf-accept-change.js +67 -0
  61. package/tools/scripts/tf-env.js +43 -43
  62. package/tools/scripts/tf-hook-add.js +151 -151
  63. package/tools/scripts/tf-hook-set.js +120 -120
  64. package/tools/scripts/tf-list.js +192 -192
  65. package/tools/scripts/tf-log.js +86 -86
  66. package/tools/scripts/tf-mcp-add.js +130 -117
  67. package/tools/scripts/tf-mcp-set.js +121 -121
  68. package/tools/scripts/tf-pack.js +237 -237
  69. package/tools/scripts/tf-tool-add.js +134 -134
  70. package/tools/scripts/tf-tool-set.js +142 -142
  71. package/tools/scripts/tf-wrap.js +103 -103
  72. package/tools/scripts/uuid.js +102 -102
  73. package/tools/tools.register.json +16 -5
@@ -1,1187 +1,1246 @@
1
- 'use strict';
2
-
3
- /*
4
- * ToolFunnel management console - vanilla client. Zero dependencies, zero external
5
- * assets, works offline. Talks to the loopback JSON API served by src/ui/server.js.
6
- * Every edit is persisted server-side through the gateway's own stores (registry,
7
- * tool-state, expose-store, hook-loader), so the running MCP server sees changes
8
- * with no restart.
9
- *
10
- * Four tabs - Tools / MCPs / Hooks / Logs. The first three each have a live search box,
11
- * a collapsible Add form, and a list whose rows carry activate/deactivate + remove controls.
12
- * Logs is read-mostly: an on/off toggle bound to the logger config plus a newest-first view
13
- * of recent activity records.
14
- */
15
-
16
- (function () {
17
- // ── tiny DOM helpers ────────────────────────────────────────────────────────
18
- var $ = function (sel) { return document.querySelector(sel); };
19
- function el(tag, attrs, children) {
20
- var node = document.createElement(tag);
21
- if (attrs) {
22
- Object.keys(attrs).forEach(function (k) {
23
- if (k === 'class') node.className = attrs[k];
24
- else if (k === 'text') node.textContent = attrs[k];
25
- else if (k === 'html') node.innerHTML = attrs[k];
26
- else if (k.slice(0, 2) === 'on' && typeof attrs[k] === 'function') node.addEventListener(k.slice(2), attrs[k]);
27
- else if (attrs[k] === true) node.setAttribute(k, '');
28
- else if (attrs[k] != null && attrs[k] !== false) node.setAttribute(k, attrs[k]);
29
- });
30
- }
31
- (children || []).forEach(function (c) {
32
- if (c == null) return;
33
- node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
34
- });
35
- return node;
36
- }
37
-
38
- // ── fetch wrappers ──────────────────────────────────────────────────────────
39
- function getJson(url) {
40
- return fetch(url, { headers: { Accept: 'application/json' } }).then(function (r) { return r.json(); });
41
- }
42
- function postJson(url, body) {
43
- return fetch(url, {
44
- method: 'POST',
45
- headers: { 'Content-Type': 'application/json' },
46
- body: JSON.stringify(body),
47
- }).then(function (r) { return r.json(); });
48
- }
49
-
50
- // ── toast ─────────────────────────────────────────────────────────────────--
51
- var toastTimer = null;
52
- function toast(msg, kind) {
53
- var t = $('#toast');
54
- t.innerHTML = '';
55
- t.appendChild(typeof msg === 'string' ? document.createTextNode(msg) : msg);
56
- t.className = 'toast show' + (kind === 'error' ? ' error' : kind === 'good' ? ' good' : '');
57
- if (toastTimer) clearTimeout(toastTimer);
58
- toastTimer = setTimeout(function () { t.className = 'toast'; }, 6000);
59
- }
60
- function errText(e) { return e && e.message ? e.message : String(e); }
61
-
62
- // ── status counts ─────────────────────────────────────────────────────────--
63
- function refreshStatus() {
64
- return getJson('/api/status').then(function (s) {
65
- $('#stat-tools').textContent = s && typeof s.tools === 'number' ? s.tools : '0';
66
- $('#stat-upstreams').textContent = s && typeof s.upstreams === 'number' ? s.upstreams : '0';
67
- $('#stat-hooks').textContent = s && typeof s.hooks === 'number' ? s.hooks : '0';
68
- }).catch(function () { /* leave placeholders */ });
69
- }
70
-
71
- // ── shared controls ─────────────────────────────────────────────────────────
72
- function makeSwitch(checked, small, onChange) {
73
- var input = el('input', { type: 'checkbox' });
74
- input.checked = !!checked;
75
- input.addEventListener('change', function () { onChange(input); });
76
- return el('label', { class: 'switch' + (small ? ' small' : '') }, [input, el('span', { class: 'slider' })]);
77
- }
78
- function toggleGroup(label, extraClass, sw) {
79
- return el('div', { class: 'toggle-group ' + extraClass }, [sw, el('span', { class: 'toggle-label', text: label })]);
80
- }
81
- // A small destructive control with an inline confirm guard.
82
- function removeButton(label, onConfirm) {
83
- return el('button', {
84
- class: 'btn-remove', type: 'button', title: 'Remove',
85
- onclick: function () { if (window.confirm(label)) onConfirm(); },
86
- }, ['Remove']);
87
- }
88
- // POST helper that drives a control disabled/revert/toast lifecycle. `apply` runs on success.
89
- function driveSwitch(input, url, body, apply, onFail) {
90
- input.disabled = true;
91
- postJson(url, body).then(function (res) {
92
- input.disabled = false;
93
- if (!res || res.ok !== true) { onFail(); toast((res && res.error) || 'Action failed', 'error'); return; }
94
- apply(res);
95
- refreshStatus();
96
- }).catch(function (e) { input.disabled = false; onFail(); toast('Request failed: ' + errText(e), 'error'); });
97
- }
98
-
99
- // ── generic filter ───────────────────────────────────────────────────────--
100
- function filterList(items, query, hayFn) {
101
- var q = (query || '').trim().toLowerCase();
102
- if (!q) return items.slice();
103
- return items.filter(function (it) { return hayFn(it).toLowerCase().indexOf(q) !== -1; });
104
- }
105
- // The authored innerHTML of each empty div (the rich "nothing configured" state) is
106
- // captured once so it can be restored after a "no matches" message overwrote it.
107
- var emptyOriginal = {};
108
- function renderInto(listSel, emptySel, countSel, shown, total, renderFn, noMatchText) {
109
- var list = $(listSel);
110
- list.innerHTML = '';
111
- shown.forEach(function (it) { list.appendChild(renderFn(it)); });
112
- var empty = $(emptySel);
113
- if (emptyOriginal[emptySel] === undefined) emptyOriginal[emptySel] = empty.innerHTML;
114
- if (shown.length === 0) {
115
- empty.hidden = false;
116
- if (total === 0) empty.innerHTML = emptyOriginal[emptySel]; // nothing configured
117
- else empty.textContent = noMatchText; // filtered to nothing
118
- } else {
119
- empty.hidden = true;
120
- }
121
- $(countSel).textContent = (shown.length !== total) ? (shown.length + '/' + total) : '';
122
- }
123
-
124
- // ── collapsible add forms ────────────────────────────────────────────────--
125
- function wireAddForm(btnSel, formSel, cancelSel, onReset) {
126
- var btn = $(btnSel), form = $(formSel);
127
- function open(show) {
128
- form.hidden = !show;
129
- btn.setAttribute('aria-expanded', show ? 'true' : 'false');
130
- btn.classList.toggle('is-open', show);
131
- if (show) { var f = form.querySelector('input, select, textarea'); if (f) f.focus(); }
132
- }
133
- btn.addEventListener('click', function () { open(form.hidden); });
134
- $(cancelSel).addEventListener('click', function () { onReset(); open(false); });
135
- return { open: open };
136
- }
137
-
138
- /* ══════════════════════════════ TOOLS ══════════════════════════════ */
139
- var tools = [];
140
-
141
- function renderTool(t) {
142
- var card = el('div', { class: 'row' + (t.enabled ? '' : ' is-disabled') + (t.hot ? ' is-hot' : ''), 'data-id': t.id });
143
-
144
- var head = el('div', { class: 'row-head' }, [
145
- el('span', { class: 'row-name', text: t.name || t.id }),
146
- t.category ? el('span', { class: 'chip', text: t.category }) : null,
147
- el('span', { class: 'row-id', text: t.id }),
148
- ]);
149
- var summary = el('p', { class: 'row-summary', text: t.summary || 'No summary.' });
150
-
151
- // Execution-mode toggle (reference ↔ gateway). checked = gateway.
152
- var modeSwitch = makeSwitch(t.mode === 'gateway', true, function (input) {
153
- var want = input.checked ? 'gateway' : 'reference';
154
- driveSwitch(input, '/api/tools/mode', { id: t.id, mode: want },
155
- function (res) { t.mode = (res && res.mode) || want; modeLabel.textContent = t.mode; },
156
- function () { input.checked = (t.mode === 'gateway'); });
157
- });
158
- var modeLabel = el('span', { class: 'toggle-label mode-label', text: t.mode || 'reference' });
159
- var modeGroup = el('div', { class: 'toggle-group mode' }, [modeSwitch, modeLabel]);
160
-
161
- // Pre / Post hook gates (POST /api/tools/hook). The switch reflects the tool's OWN literal
162
- // entry (preOwn/postOwn), not "some broader hook fires" - a wildcard gate is not this
163
- // switch's state, and rendering it as such made OFF toast a removal that never happened and
164
- // then snap back on refresh. The server's stillGated/note is surfaced, and
165
- // the panel re-fetches after every change instead of trusting the optimistic value.
166
- function hookSwitch(eventName, label, current) {
167
- return toggleGroup(label, 'hook', makeSwitch(current, true, function (input) {
168
- var want = input.checked;
169
- driveSwitch(input, '/api/tools/hook', { id: t.id, event: eventName, on: want },
170
- function (res) {
171
- if (res && res.note && res.stillGated) {
172
- toast(el('span', {}, [label + ' gate: ', res.note]), 'warn');
173
- } else if (want && res.scriptPath) {
174
- toast(el('span', {}, [label + ' gate enabled for ', el('code', { text: t.id }), '. ', el('code', { text: res.scriptPath })]));
175
- } else if (want) { toast(label + ' gate enabled for ' + t.id, 'good'); }
176
- else { toast(label + ' gate removed for ' + t.id, 'good'); }
177
- loadTools(); // re-render from the server's truth, never the optimistic flag
178
- },
179
- function () { input.checked = !want; });
180
- }));
181
- }
182
-
183
- // Enable / disable - LEAN visibility (POST /api/tools/state).
184
- var enableSwitch = makeSwitch(t.enabled, false, function (input) {
185
- var want = input.checked;
186
- driveSwitch(input, '/api/tools/state', { id: t.id, enabled: want },
187
- // A disabled tool is never on the every-turn surface, so refresh the panel (count + warnings)
188
- // - disabling a hot tool drops it from the surface, re-enabling restores it.
189
- function () { t.enabled = want; card.classList.toggle('is-disabled', !want); loadSurface(); },
190
- function () { input.checked = !want; });
191
- });
192
-
193
- // Hot - promote to the TOP-LEVEL every-turn surface (POST /api/tools/state {hot}). A hot tool is
194
- // injected into the AI's context every turn AND becomes directly callable. Refresh the surface
195
- // panel (count + bloat warnings) after a change.
196
- var hotSwitch = makeSwitch(t.hot, false, function (input) {
197
- var want = input.checked;
198
- driveSwitch(input, '/api/tools/state', { id: t.id, hot: want },
199
- function () { t.hot = want; card.classList.toggle('is-hot', want); loadSurface(); },
200
- function () { input.checked = !want; });
201
- });
202
-
203
- // Hidden - declutter THIS manager list only (does not change what the AI sees). Re-filter on
204
- // change so the row drops out when hidden (unless "show hidden" is on).
205
- var hiddenSwitch = makeSwitch(t.hidden, false, function (input) {
206
- var want = input.checked;
207
- driveSwitch(input, '/api/tools/state', { id: t.id, hidden: want },
208
- function () { t.hidden = want; applyToolFilter(); },
209
- function () { input.checked = !want; });
210
- });
211
-
212
- // Details / edit - a lazy inline panel showing the full entry (instructions, invoke, script body)
213
- // with editable fields + Save. Fetched from /api/tools/detail on first expand.
214
- var detailPanel = el('div', { class: 'tool-detail', hidden: true });
215
- var detailLoaded = false;
216
- var detailsBtn = el('button', { class: 'btn btn-ghost btn-sm tool-details-btn', type: 'button' }, ['Details / edit']);
217
- detailsBtn.addEventListener('click', function () {
218
- var show = detailPanel.hidden;
219
- detailPanel.hidden = !show;
220
- detailsBtn.classList.toggle('is-open', show);
221
- if (show && !detailLoaded) { detailLoaded = true; loadToolDetail(t, detailPanel); }
222
- });
223
-
224
- var controls = el('div', { class: 'row-controls' }, [
225
- modeGroup,
226
- el('div', { class: 'hook-toggles' }, [
227
- hookSwitch('PreToolUse', 'Pre', t.preOwn === undefined ? t.pre : t.preOwn),
228
- hookSwitch('PostToolUse', 'Post', t.postOwn === undefined ? t.post : t.postOwn),
229
- // Covered by a broader hook the switch does not own: say so instead of faking the switch.
230
- (t.pre && !t.preOwn) || (t.post && !t.postOwn)
231
- ? el('span', { class: 'chip', title: 'A broader hook (wildcard or regex) also gates this tool. Manage it on the Hooks tab.', text: 'gated by broader hook' })
232
- : null,
233
- ]),
234
- toggleGroup('Enabled', 'enable', enableSwitch),
235
- toggleGroup('Hot', 'hot', hotSwitch),
236
- toggleGroup('Hidden', 'hidden', hiddenSwitch),
237
- detailsBtn,
238
- removeButton('Remove tool "' + t.id + '" from the register?', function () {
239
- postJson('/api/tools/remove', { id: t.id }).then(function (res) {
240
- if (!res || res.ok !== true) { toast((res && res.error) || 'Remove failed', 'error'); return; }
241
- toast('Removed tool ' + t.id, 'good');
242
- loadTools(); refreshStatus();
243
- }).catch(function (e) { toast('Request failed: ' + errText(e), 'error'); });
244
- }),
245
- ]);
246
-
247
- card.appendChild(head); card.appendChild(summary); card.appendChild(controls); card.appendChild(detailPanel);
248
- return card;
249
- }
250
-
251
- // Fetch one tool's full detail and render the editor into `panel`.
252
- function loadToolDetail(t, panel) {
253
- panel.innerHTML = '';
254
- panel.appendChild(el('p', { class: 'muted', text: 'Loading...' }));
255
- getJson('/api/tools/detail?id=' + encodeURIComponent(t.id)).then(function (res) {
256
- panel.innerHTML = '';
257
- if (!res || res.ok !== true || !res.entry) {
258
- panel.appendChild(el('p', { class: 'muted', text: (res && res.error) || 'Failed to load details.' }));
259
- return;
260
- }
261
- renderToolEditor(t, panel, res.entry, res.scriptText);
262
- }).catch(function (e) {
263
- panel.innerHTML = '';
264
- panel.appendChild(el('p', { class: 'muted', text: 'Failed to load details: ' + errText(e) }));
265
- });
266
- }
267
-
268
- // Build the editable detail form for one tool. Save -> POST /api/tools/update.
269
- function renderToolEditor(t, panel, entry, scriptText) {
270
- var inv = entry.invoke || {};
271
- function field(label, hint, control) {
272
- return el('label', { class: 'field field-wide' }, [
273
- el('span', {}, [label, hint ? el('em', { class: 'hint', text: ' ' + hint }) : null]),
274
- control,
275
- ]);
276
- }
277
- var fName = el('input', { type: 'text', value: entry.name || '' });
278
- var fSummary = el('input', { type: 'text', value: entry.summary || '' });
279
- var fCategory = el('input', { type: 'text', value: entry.category || '' });
280
- var fMode = el('select', {}, [
281
- el('option', { value: 'gateway', text: 'gateway - run here' }),
282
- el('option', { value: 'reference', text: 'reference - AI runs it' }),
283
- ]);
284
- fMode.value = (t.mode === 'reference') ? 'reference' : 'gateway';
285
- var fInstr = el('textarea', { rows: '4', spellcheck: 'false' }); fInstr.value = entry.instructions || '';
286
-
287
- var fType = el('select', {}, [
288
- el('option', { value: 'script', text: 'script' }),
289
- el('option', { value: 'shell', text: 'shell' }),
290
- el('option', { value: 'none', text: 'none (reference)' }),
291
- ]);
292
- fType.value = inv.type || 'none';
293
- var fPath = el('input', { type: 'text', value: inv.path || '', spellcheck: 'false' });
294
- var fCmd = el('input', { type: 'text', value: inv.command || '', spellcheck: 'false' });
295
- var fScript = el('textarea', { rows: '8', spellcheck: 'false' }); fScript.value = scriptText || '';
296
-
297
- var pathField = field('script path', '', fPath);
298
- var cmdField = field('shell command', '', fCmd);
299
- var scriptField = field('script body', '(authored under tools/scripts/)', fScript);
300
- function syncInvoke() {
301
- var ty = fType.value;
302
- pathField.hidden = ty !== 'script';
303
- cmdField.hidden = ty !== 'shell';
304
- scriptField.hidden = ty !== 'script';
305
- }
306
- fType.addEventListener('change', syncInvoke);
307
-
308
- var idTag = el('div', { class: 'tool-detail-id' }, [el('span', { class: 'row-id', text: 'id: ' + entry.id })]);
309
- var grid = el('div', { class: 'field-grid' }, [
310
- field('name', '', fName),
311
- field('summary', '', fSummary),
312
- field('category', '', fCategory),
313
- field('mode', '', fMode),
314
- field('invoke type', '', fType),
315
- pathField, cmdField,
316
- field('instructions', '', fInstr),
317
- scriptField,
318
- ]);
319
-
320
- var saveBtn = el('button', { class: 'btn btn-primary', type: 'button' }, ['Save changes']);
321
- saveBtn.addEventListener('click', function () {
322
- var patch = {
323
- name: fName.value.trim(),
324
- summary: fSummary.value.trim(),
325
- category: fCategory.value.trim(),
326
- instructions: fInstr.value,
327
- mode: fMode.value,
328
- };
329
- var ty = fType.value;
330
- if (ty === 'script') { patch.invoke = { type: 'script', path: fPath.value.trim() }; }
331
- else if (ty === 'shell') { patch.invoke = { type: 'shell', command: fCmd.value.trim() }; }
332
- var body = { id: entry.id, patch: patch };
333
- if (ty === 'script' && fScript.value.length) body.scriptText = fScript.value;
334
- saveBtn.disabled = true;
335
- postJson('/api/tools/update', body).then(function (res) {
336
- saveBtn.disabled = false;
337
- if (!res || res.ok !== true) { toast((res && res.error) || 'Save failed', 'error'); return; }
338
- toast('Saved ' + entry.id, 'good');
339
- loadTools(); refreshStatus();
340
- }).catch(function (e) { saveBtn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
341
- });
342
-
343
- panel.appendChild(idTag);
344
- panel.appendChild(grid);
345
- panel.appendChild(el('div', { class: 'addform-actions' }, [saveBtn]));
346
- syncInvoke();
347
- }
348
-
349
- function applyToolFilter() {
350
- // Hidden tools are decluttered from THIS manager list unless "show hidden" is ticked. (This is the
351
- // manager view only - `hidden` never affects the lean list / top-level surface the AI sees.)
352
- var showHidden = $('#show-hidden') && $('#show-hidden').checked;
353
- var base = showHidden ? tools : tools.filter(function (t) { return !t.hidden; });
354
- var shown = filterList(base, $('#search-tools').value, function (t) {
355
- return t.id + ' ' + (t.name || '') + ' ' + (t.summary || '') + ' ' + (t.category || '') + ' ' + (t.mode || '');
356
- });
357
- // Distinguish "register empty" (tools.length===0 -> renderInto's authored message) from "all
358
- // hidden" (tools exist but the hidden filter emptied the view) - pass tools.length as `total` so
359
- // a non-empty register never reads as empty, and give an all-hidden-specific no-match message.
360
- var noMatch = (base.length === 0 && tools.length > 0)
361
- ? ('All ' + tools.length + ' tool' + (tools.length === 1 ? '' : 's') + ' are hidden - tick "show hidden" to see them.')
362
- : 'No tools match your search.';
363
- renderInto('#tool-list', '#tools-empty', '#count-tools', shown, tools.length, renderTool, noMatch);
364
- }
365
-
366
- function loadTools() {
367
- return getJson('/api/tools').then(function (data) {
368
- tools = Array.isArray(data) ? data : [];
369
- applyToolFilter();
370
- }).catch(function (e) {
371
- $('#tool-list').innerHTML = '';
372
- $('#tools-empty').hidden = false;
373
- $('#tools-empty').textContent = 'Failed to load tools: ' + errText(e);
374
- });
375
- }
376
-
377
- /* ── Top-level surface panel ─────────────────────────────────────────────────
378
- * Shows which tools are injected EVERY turn: the 4 meta-tools (default on, each
379
- * toggleable), the count of promoted tools, and footgun warnings (hiding the
380
- * management tools / promoting too many = context bloat). Reads /api/surface. */
381
- function loadSurface() {
382
- return getJson('/api/surface').then(renderSurface).catch(function () { /* leave last state */ });
383
- }
384
- function renderSurface(data) {
385
- var meta = (data && Array.isArray(data.meta)) ? data.meta : [];
386
- var metaBox = $('#surface-meta');
387
- metaBox.innerHTML = '';
388
- meta.forEach(function (m) {
389
- var sw = makeSwitch(m.hot, true, function (input) {
390
- var want = input.checked;
391
- driveSwitch(input, '/api/tools/state', { id: m.name, hot: want },
392
- function () { m.hot = want; loadSurface(); },
393
- function () { input.checked = !want; });
394
- });
395
- metaBox.appendChild(el('div', { class: 'surface-meta-item' + (m.hot ? '' : ' is-off') }, [sw, el('code', { text: m.name })]));
396
- });
397
- var total = (data && typeof data.promotedTotal === 'number') ? data.promotedTotal : 0;
398
- var countEl = $('#surface-count');
399
- countEl.textContent = total ? (total + ' promoted') : 'lean';
400
- countEl.className = 'surface-count' + (total > 10 ? ' is-bloat' : total ? ' is-some' : '');
401
-
402
- var warnBox = $('#surface-warnings');
403
- var warnings = (data && Array.isArray(data.warnings)) ? data.warnings : [];
404
- warnBox.innerHTML = '';
405
- if (warnings.length) {
406
- warnings.forEach(function (w) { warnBox.appendChild(el('p', { class: 'surface-warning', text: '⚠ ' + w })); });
407
- warnBox.hidden = false;
408
- } else {
409
- warnBox.hidden = true;
410
- }
411
- }
412
-
413
- function resetToolForm() {
414
- ['#t-id', '#t-name', '#t-category', '#t-summary', '#t-path', '#t-command', '#t-instructions', '#t-scripttext']
415
- .forEach(function (s) { $(s).value = ''; });
416
- $('#t-mode').value = 'gateway';
417
- $('#t-invoke-type').value = 'script';
418
- syncToolFields();
419
- }
420
- // Show/hide invoke fields by mode + invoke type.
421
- function syncToolFields() {
422
- var mode = $('#t-mode').value;
423
- var type = $('#t-invoke-type').value;
424
- var ref = mode === 'reference';
425
- $('#t-invoke-type-field').hidden = ref;
426
- $('#t-path-field').hidden = ref || type !== 'script';
427
- $('#t-command-field').hidden = ref || type !== 'shell';
428
- $('#t-script-field').hidden = ref || type !== 'script';
429
- }
430
-
431
- function submitTool(ev) {
432
- ev.preventDefault();
433
- var id = $('#t-id').value.trim(), name = $('#t-name').value.trim();
434
- if (!id) { toast('Tool id is required', 'error'); return; }
435
- if (!name) { toast('Tool name is required', 'error'); return; }
436
- var mode = $('#t-mode').value;
437
- var entry = { id: id, name: name, mode: mode };
438
- var v;
439
- if ((v = $('#t-summary').value.trim())) entry.summary = v;
440
- if ((v = $('#t-category').value.trim())) entry.category = v;
441
- if ((v = $('#t-instructions').value.trim())) entry.instructions = v;
442
- if (mode !== 'reference') {
443
- var type = $('#t-invoke-type').value;
444
- if (type === 'script') {
445
- var path = $('#t-path').value.trim();
446
- if (!path) { toast('Script path is required for a script invoke', 'error'); return; }
447
- entry.invoke = { type: 'script', path: path };
448
- var body = $('#t-scripttext').value;
449
- if (body && body.length) entry.scriptText = body;
450
- } else if (type === 'shell') {
451
- var cmd = $('#t-command').value.trim();
452
- if (!cmd) { toast('Shell command is required for a shell invoke', 'error'); return; }
453
- entry.invoke = { type: 'shell', command: cmd };
454
- }
455
- }
456
- var submit = $('#t-submit'); submit.disabled = true;
457
- postJson('/api/tools/add', { entry: entry }).then(function (res) {
458
- submit.disabled = false;
459
- if (!res || res.ok !== true) { toast((res && res.error) || 'Add failed', 'error'); return; }
460
- toast('Added tool ' + id, 'good');
461
- resetToolForm(); toolForm.open(false);
462
- loadTools(); refreshStatus();
463
- }).catch(function (e) { submit.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
464
- }
465
-
466
- /* ══════════════════════════════ MCPs ══════════════════════════════ */
467
- var upstreams = [];
468
- var exposeByUpstream = {};
469
- var wrapping = null; // the active passthrough wrap (upstream id) or null
470
-
471
- function renderUpstream(u) {
472
- var exposed = exposeByUpstream[u.id] || [];
473
- var isWrapped = wrapping === u.id;
474
- var card = el('div', { class: 'row' + (u.enabled ? '' : ' is-disabled'), 'data-id': u.id });
475
-
476
- var head = el('div', { class: 'row-head' }, [
477
- el('span', { class: 'row-name mono', text: u.id }),
478
- el('span', { class: 'badge', text: u.transport || 'stdio' }),
479
- el('span', { class: 'badge ' + (u.enabled ? 'on' : 'off'), text: u.enabled ? 'enabled' : 'disabled' }),
480
- u.legacyPin === true ? el('span', { class: 'badge off', text: 'legacy-pinned (MCP 2024-11-05)' }) : null,
481
- isWrapped ? el('span', { class: 'badge on', text: 'WRAPPED - this MCP is the whole surface' }) : null,
482
- ]);
483
- var cmd = el('p', { class: 'row-cmd', text: [u.command].concat(u.args || []).join(' ') });
484
- var desc = u.description ? el('p', { class: 'row-summary', text: u.description }) : null;
485
-
486
- var exposedRows = exposed.length
487
- ? exposed.map(function (e) {
488
- return el('div', { class: 'exposed-row' }, [
489
- el('span', { class: 'tool', text: e.tool }),
490
- el('span', { class: 'arrow', text: '->' }),
491
- el('span', { class: 'as', text: e.as || (u.id + '_' + e.tool) }),
492
- e.category ? el('span', { class: 'chip', text: e.category }) : null,
493
- el('span', { class: 'badge ' + (e.enabled ? 'on' : 'off'), text: e.enabled ? 'on' : 'off' }),
494
- ]);
495
- })
496
- : [el('div', { class: 'exposed-row' }, [el('span', { class: 'none', text: 'No exposed tools.' })])];
497
- var exposedBlock = el('div', { class: 'exposed' }, [el('p', { class: 'exposed-title', text: 'Exposed tools' })].concat(exposedRows));
498
-
499
- var enableSwitch = makeSwitch(u.enabled, false, function (input) {
500
- var want = input.checked;
501
- driveSwitch(input, '/api/mcp/state', { id: u.id, action: want ? 'enable' : 'disable' },
502
- function () { u.enabled = want; card.classList.toggle('is-disabled', !want); loadMcps(); },
503
- function () { input.checked = !want; });
504
- });
505
-
506
- // legacyPin (0.6.0): keep speaking MCP 2024-11-05 to this upstream forever - never auto-
507
- // upgrades, warns at startup and per call. Opt-in; takes effect on the next (re)connect.
508
- var pinSwitch = makeSwitch(u.legacyPin === true, true, function (input) {
509
- var want = input.checked;
510
- driveSwitch(input, '/api/mcp/state', { id: u.id, action: want ? 'pin' : 'unpin' },
511
- function () { u.legacyPin = want; loadMcps(); },
512
- function () { input.checked = !want; });
513
- });
514
-
515
- // Discovered-tools area (filled by the Discover button) - a LIVE connect + tools/list, then a
516
- // per-tool Lean (enabled) + Hot toggle keyed by each tool's SURFACED name (what the gateway reads).
517
- var discovered = el('div', { class: 'discovered', hidden: true });
518
- var discoverBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button', title: 'Connect to this upstream and list its tools' }, ['Discover tools']);
519
- discoverBtn.addEventListener('click', function () {
520
- discoverBtn.disabled = true;
521
- var label = discoverBtn.textContent; discoverBtn.textContent = 'Discovering...';
522
- postJson('/api/mcp/discover', { id: u.id }).then(function (res) {
523
- discoverBtn.disabled = false; discoverBtn.textContent = label;
524
- if (!res || res.ok !== true) {
525
- toast((res && res.error) || 'Discover failed', 'error');
526
- renderDiscovered(discovered, []);
527
- discovered.hidden = false;
528
- return;
529
- }
530
- var n = res.tools ? res.tools.length : 0;
531
- toast(n + ' tool' + (n === 1 ? '' : 's') + ' discovered on ' + u.id, 'good');
532
- renderDiscovered(discovered, res.tools || []);
533
- discovered.hidden = false;
534
- }).catch(function (e) { discoverBtn.disabled = false; discoverBtn.textContent = label; toast('Request failed: ' + errText(e), 'error'); });
535
- });
536
-
537
- // Wrap / Unwrap - the passthrough control. Wrapping makes THIS upstream's tools the entire
538
- // MCP surface (original names); everything else, meta-tools included, disappears until
539
- // unwrapped. This button is one of the two recovery paths from an AI-set wrap (the other is
540
- // `toolfunnel wrap --off`), so it must always be visible and obvious.
541
- var wrapBtn = el('button', {
542
- class: 'btn btn-sm ' + (isWrapped ? 'btn-danger' : 'btn-ghost'),
543
- type: 'button',
544
- title: isWrapped
545
- ? 'Restore the normal ToolFunnel surface (meta-tools + matrix)'
546
- : 'Make this MCP’s tools the ENTIRE surface (transparent wrapper - hides all ToolFunnel tools)',
547
- }, [isWrapped ? 'Unwrap' : 'Wrap']);
548
- wrapBtn.addEventListener('click', function () {
549
- if (!isWrapped) {
550
- var ok = window.confirm(
551
- 'Wrap "' + u.id + '"?\n\nIts tools become the ENTIRE MCP surface, under their original names. ' +
552
- 'ALL ToolFunnel tools (meta-tools included) become hidden and uncallable until you unwrap - ' +
553
- 'here, or with: toolfunnel wrap --off\n\nEvery call still passes the PreToolUse gate.'
554
- );
555
- if (!ok) return;
556
- }
557
- wrapBtn.disabled = true;
558
- postJson('/api/wrap', { upstream: isWrapped ? null : u.id }).then(function (res) {
559
- wrapBtn.disabled = false;
560
- if (!res || res.ok !== true) { toast((res && res.error) || 'Wrap change failed', 'error'); return; }
561
- toast(isWrapped ? 'Unwrapped - normal surface restored' : 'Wrapped ' + u.id + ' - its tools are now the whole surface', 'good');
562
- // Same informed consent the CLI gives: outside paths are PERMITTED under a wrap (the
563
- // isolation guard is suspended for this upstream) - say so, prominently.
564
- if (res.warning) window.alert(res.warning);
565
- loadMcps(); refreshStatus();
566
- }).catch(function (e) { wrapBtn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
567
- });
568
-
569
- var controls = el('div', { class: 'row-controls' }, [
570
- toggleGroup('Enabled', 'enable', enableSwitch),
571
- toggleGroup('Legacy pin', 'pin', pinSwitch),
572
- discoverBtn,
573
- wrapBtn,
574
- removeButton('Remove upstream "' + u.id + '" (and its exposed tools)?', function () {
575
- postJson('/api/mcp/state', { id: u.id, action: 'remove' }).then(function (res) {
576
- if (!res || res.ok !== true) { toast((res && res.error) || 'Remove failed', 'error'); return; }
577
- toast('Removed upstream ' + u.id, 'good');
578
- loadMcps(); refreshStatus();
579
- }).catch(function (e) { toast('Request failed: ' + errText(e), 'error'); });
580
- }),
581
- ]);
582
-
583
- card.appendChild(head);
584
- if (desc) card.appendChild(desc);
585
- card.appendChild(cmd);
586
- card.appendChild(exposedBlock);
587
- card.appendChild(controls);
588
- card.appendChild(discovered);
589
- return card;
590
- }
591
-
592
- // Render the LIVE-discovered tools of one upstream, each with a Lean (enabled) + Hot toggle keyed
593
- // by its SURFACED name (an enabled expose `as` else `<upstream>_<tool>`) - the exact key the running
594
- // gateway reads, so a toggle here is byte-identical to editing tools.state.json.
595
- function renderDiscovered(container, tools) {
596
- container.innerHTML = '';
597
- if (!tools.length) {
598
- container.appendChild(el('p', { class: 'none', text: 'No tools discovered (the upstream did not connect, or advertises none).' }));
599
- return;
600
- }
601
- container.appendChild(el('p', { class: 'exposed-title', text: 'Discovered tools - curate lean visibility / promote hot' }));
602
- tools.forEach(function (t) {
603
- var leanSwitch = makeSwitch(t.enabled, true, function (input) {
604
- var want = input.checked;
605
- driveSwitch(input, '/api/tools/state', { id: t.name, enabled: want },
606
- // Disabling a hot upstream tool drops it from the surface too - refresh the panel.
607
- function () { t.enabled = want; loadSurface(); }, function () { input.checked = !want; });
608
- });
609
- var hotSwitch = makeSwitch(t.hot, true, function (input) {
610
- var want = input.checked;
611
- driveSwitch(input, '/api/tools/state', { id: t.name, hot: want },
612
- function () { t.hot = want; loadSurface(); }, function () { input.checked = !want; });
613
- });
614
- container.appendChild(el('div', { class: 'discovered-row' }, [
615
- el('span', { class: 'tool mono', text: t.tool }),
616
- el('span', { class: 'arrow', text: '->' }),
617
- el('span', { class: 'as mono', text: t.name }),
618
- t.description ? el('span', { class: 'disc-desc', text: t.description }) : null,
619
- toggleGroup('Lean', 'lean', leanSwitch),
620
- toggleGroup('Hot', 'hot', hotSwitch),
621
- ]));
622
- });
623
- }
624
-
625
- function applyMcpFilter() {
626
- var shown = filterList(upstreams, $('#search-mcps').value, function (u) {
627
- return u.id + ' ' + (u.command || '') + ' ' + (u.args || []).join(' ') + ' ' + (u.description || '');
628
- });
629
- renderInto('#mcp-list', '#mcps-empty', '#count-mcps', shown, upstreams.length, renderUpstream, 'No upstreams match your search.');
630
- }
631
-
632
- // A loud, always-visible banner while a passthrough wrap is active - this page is the visual
633
- // recovery path from a wrap (the funnel's own tools are hidden from the MCP surface, so an
634
- // attached AI cannot undo it in-band; the human can, here, with one click).
635
- function renderWrapBanner() {
636
- var existing = document.getElementById('wrap-banner');
637
- if (existing) existing.remove();
638
- if (!wrapping) return;
639
- var list = $('#mcp-list');
640
- if (!list || !list.parentNode) return;
641
- var unwrapBtn = el('button', { class: 'btn btn-sm', type: 'button' }, ['Unwrap now']);
642
- unwrapBtn.addEventListener('click', function () {
643
- unwrapBtn.disabled = true;
644
- postJson('/api/wrap', { upstream: null }).then(function (res) {
645
- unwrapBtn.disabled = false;
646
- if (!res || res.ok !== true) { toast((res && res.error) || 'Unwrap failed', 'error'); return; }
647
- toast('Unwrapped - normal surface restored', 'good');
648
- loadMcps(); refreshStatus();
649
- }).catch(function (e) { unwrapBtn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
650
- });
651
- var banner = el('div', { id: 'wrap-banner', class: 'row' }, [
652
- el('p', { text: 'PASSTHROUGH ACTIVE: the gateway is wrapping "' + wrapping + '" - its tools are the entire ' +
653
- 'MCP surface (original names). All ToolFunnel tools are hidden from connected clients. Every call is still gated.' }),
654
- unwrapBtn,
655
- ]);
656
- list.parentNode.insertBefore(banner, list);
657
- }
658
-
659
- function loadMcps() {
660
- return getJson('/api/upstreams').then(function (data) {
661
- upstreams = (data && Array.isArray(data.upstreams)) ? data.upstreams : [];
662
- var expose = (data && Array.isArray(data.expose)) ? data.expose : [];
663
- wrapping = (data && typeof data.wrapping === 'string' && data.wrapping) ? data.wrapping : null;
664
- exposeByUpstream = {};
665
- expose.forEach(function (e) { (exposeByUpstream[e.upstream] = exposeByUpstream[e.upstream] || []).push(e); });
666
- renderWrapBanner();
667
- applyMcpFilter();
668
- }).catch(function (e) {
669
- $('#mcp-list').innerHTML = '';
670
- $('#mcps-empty').hidden = false;
671
- $('#mcps-empty').textContent = 'Failed to load upstreams: ' + errText(e);
672
- });
673
- }
674
-
675
- // Dynamic expose-entry rows in the MCP add form.
676
- function addExposeRow() {
677
- var row = el('div', { class: 'expose-input-row' }, [
678
- el('input', { type: 'text', class: 'x-tool', placeholder: 'tool', spellcheck: 'false' }),
679
- el('input', { type: 'text', class: 'x-as', placeholder: 'as (optional)', spellcheck: 'false' }),
680
- el('input', { type: 'text', class: 'x-cat', placeholder: 'category', spellcheck: 'false' }),
681
- el('button', { type: 'button', class: 'btn-remove btn-sm', title: 'Remove row', onclick: function () { row.remove(); } }, ['×']),
682
- ]);
683
- $('#m-expose-rows').appendChild(row);
684
- }
685
- function collectExpose() {
686
- var out = [];
687
- $('#m-expose-rows').querySelectorAll('.expose-input-row').forEach(function (r) {
688
- var tool = r.querySelector('.x-tool').value.trim();
689
- if (!tool) return;
690
- var item = { tool: tool };
691
- var as = r.querySelector('.x-as').value.trim(); if (as) item.as = as;
692
- var cat = r.querySelector('.x-cat').value.trim(); if (cat) item.category = cat;
693
- out.push(item);
694
- });
695
- return out;
696
- }
697
- function resetMcpForm() {
698
- ['#m-id', '#m-command', '#m-args', '#m-description'].forEach(function (s) { $(s).value = ''; });
699
- $('#m-transport').value = 'stdio';
700
- $('#m-expose-rows').innerHTML = '';
701
- }
702
-
703
- function submitMcp(ev) {
704
- ev.preventDefault();
705
- var id = $('#m-id').value.trim(), command = $('#m-command').value.trim();
706
- if (!id) { toast('Upstream id is required', 'error'); return; }
707
- if (!command) { toast('Command is required', 'error'); return; }
708
- var argsRaw = $('#m-args').value.trim();
709
- var upstream = { id: id, command: command, transport: $('#m-transport').value || 'stdio' };
710
- if (argsRaw) upstream.args = argsRaw.split(/\s+/);
711
- var desc = $('#m-description').value.trim(); if (desc) upstream.description = desc;
712
- var payload = { upstream: upstream };
713
- var expose = collectExpose();
714
- if (expose.length) payload.expose = expose;
715
- var submit = $('#m-submit'); submit.disabled = true;
716
- postJson('/api/mcp/add', payload).then(function (res) {
717
- submit.disabled = false;
718
- if (!res || res.ok !== true) { toast((res && res.error) || 'Add failed', 'error'); return; }
719
- toast('Added upstream ' + id, 'good');
720
- resetMcpForm(); mcpForm.open(false);
721
- loadMcps(); refreshStatus();
722
- }).catch(function (e) { submit.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
723
- }
724
-
725
- /* ══════════════════════════════ HOOKS ══════════════════════════════ */
726
- var EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'PreCompact'];
727
- var hooks = [];
728
-
729
- function renderHook(h) {
730
- var hasId = !!h.id;
731
- var card = el('div', { class: 'row' + (h.enabled ? '' : ' is-disabled'), 'data-id': h.id || '' });
732
-
733
- var head = el('div', { class: 'row-head' }, [
734
- el('span', { class: 'row-name mono', text: h.id || '(tool gate)' }),
735
- el('span', { class: 'chip', text: h.event || '?' }),
736
- hasId ? null : el('span', { class: 'badge', text: 'auto' }),
737
- ]);
738
- var meta = el('p', { class: 'row-cmd', text: 'matcher: ' + (h.matcher && h.matcher.length ? h.matcher : '* (all)') });
739
- var desc = h.description ? el('p', { class: 'row-summary', text: h.description }) : null;
740
-
741
- var controls;
742
- if (hasId) {
743
- var enableSwitch = makeSwitch(h.enabled, false, function (input) {
744
- var want = input.checked;
745
- driveSwitch(input, '/api/hooks/state', { id: h.id, action: want ? 'enable' : 'disable' },
746
- function () { h.enabled = want; card.classList.toggle('is-disabled', !want); },
747
- function () { input.checked = !want; });
748
- });
749
- controls = el('div', { class: 'row-controls' }, [
750
- toggleGroup('Enabled', 'enable', enableSwitch),
751
- removeButton('Remove hook "' + h.id + '"? (its script file is left in place)', function () {
752
- postJson('/api/hooks/state', { id: h.id, action: 'remove' }).then(function (res) {
753
- if (!res || res.ok !== true) { toast((res && res.error) || 'Remove failed', 'error'); return; }
754
- toast('Removed hook ' + h.id, 'good');
755
- loadHooks(); refreshStatus();
756
- }).catch(function (e) { toast('Request failed: ' + errText(e), 'error'); });
757
- }),
758
- ]);
759
- } else {
760
- // Tool Pre/Post gate (no id) - managed from the Tools tab; show state read-only.
761
- controls = el('div', { class: 'row-controls' }, [
762
- el('span', { class: 'badge ' + (h.enabled ? 'on' : 'off'), text: h.enabled ? 'enabled' : 'disabled' }),
763
- el('span', { class: 'managed-note', text: 'managed on Tools tab' }),
764
- ]);
765
- }
766
-
767
- card.appendChild(head);
768
- if (desc) card.appendChild(desc);
769
- card.appendChild(meta);
770
- card.appendChild(controls);
771
- return card;
772
- }
773
-
774
- function applyHookFilter() {
775
- var shown = filterList(hooks, $('#search-hooks').value, function (h) {
776
- return (h.id || '') + ' ' + (h.event || '') + ' ' + (h.matcher || '') + ' ' + (h.description || '');
777
- });
778
- renderInto('#hook-list', '#hooks-empty', '#count-hooks', shown, hooks.length, renderHook, 'No hooks match your search.');
779
- }
780
-
781
- function loadHooks() {
782
- return getJson('/api/hooks').then(function (data) {
783
- hooks = (data && Array.isArray(data.hooks)) ? data.hooks : [];
784
- applyHookFilter();
785
- }).catch(function (e) {
786
- $('#hook-list').innerHTML = '';
787
- $('#hooks-empty').hidden = false;
788
- $('#hooks-empty').textContent = 'Failed to load hooks: ' + errText(e);
789
- });
790
- }
791
-
792
- function resetHookForm() {
793
- ['#h-id', '#h-matcher', '#h-command', '#h-scripttext'].forEach(function (s) { $(s).value = ''; });
794
- $('#h-event').value = EVENTS[2]; // PreToolUse default
795
- }
796
-
797
- function submitHook(ev) {
798
- ev.preventDefault();
799
- var id = $('#h-id').value.trim(), command = $('#h-command').value.trim();
800
- if (!id) { toast('Hook id is required', 'error'); return; }
801
- if (!command) { toast('Command is required', 'error'); return; }
802
- var entry = { id: id, event: $('#h-event').value, command: command };
803
- var matcher = $('#h-matcher').value.trim(); if (matcher) entry.matcher = matcher;
804
- var body = $('#h-scripttext').value; if (body && body.length) entry.scriptText = body;
805
- var submit = $('#h-submit'); submit.disabled = true;
806
- postJson('/api/hooks/add', { entry: entry }).then(function (res) {
807
- submit.disabled = false;
808
- if (!res || res.ok !== true) { toast((res && res.error) || 'Add failed', 'error'); return; }
809
- toast('Added hook ' + id + ' (disabled - enable it to fire)', 'good');
810
- resetHookForm(); hookForm.open(false);
811
- loadHooks(); refreshStatus();
812
- }).catch(function (e) { submit.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
813
- }
814
-
815
- /* ══════════════════════════════ LOGS ══════════════════════════════ */
816
- // The activity log is a toggleable JSONL store (DEFAULT OFF). This tab binds an on/off
817
- // switch to /api/logs/config and shows the most recent records newest-first.
818
- var logConfig = { enabled: false, path: '' };
819
- var logEntries = [];
820
-
821
- // Reflect the current config in the switch, status word, and (when on) the log path.
822
- function updateLogStatus() {
823
- $('#log-enabled').checked = !!logConfig.enabled;
824
- var status = $('#log-status');
825
- status.textContent = logConfig.enabled ? 'logging is on' : 'logging is off';
826
- status.className = 'log-status' + (logConfig.enabled ? ' on' : '');
827
- $('#log-path').textContent = (logConfig.enabled && logConfig.path) ? logConfig.path : '';
828
- }
829
-
830
- function loadLogConfig() {
831
- return getJson('/api/logs/config').then(function (cfg) {
832
- logConfig = {
833
- enabled: !!(cfg && cfg.enabled),
834
- path: (cfg && typeof cfg.path === 'string') ? cfg.path : '',
835
- };
836
- updateLogStatus();
837
- }).catch(function () {
838
- logConfig = { enabled: false, path: '' };
839
- updateLogStatus();
840
- });
841
- }
842
-
843
- // Classify a record for its small type badge. The gateway emits type:'gate' / type:'tool';
844
- // fall back defensively (a record carrying a `decision` is a gate, else a tool).
845
- function logKind(e) {
846
- var t = e && e.type;
847
- if (t === 'gate' || t === 'tool' || t === 'config' || t === 'auth' || t === 'client' || t === 'mcp') return t;
848
- if (e && e.decision != null) return 'gate';
849
- if (e && (e.tool || e.ok != null)) return 'tool';
850
- return 'event';
851
- }
852
- // The status word + tone per kind: gate -> allow/deny, tool -> ok/fail (blocked counts as fail).
853
- function logStatusOf(e, kind) {
854
- if (kind === 'gate') {
855
- var d = (e && e.decision) || '?';
856
- return { text: d, tone: d === 'deny' ? 'bad' : (d === 'allow' ? 'good' : '') };
857
- }
858
- if (kind === 'auth') {
859
- var ev = (e && e.event) || '?';
860
- return { text: ev, tone: ev === 'deny' ? 'bad' : (ev === 'allow' ? 'good' : '') };
861
- }
862
- if (kind === 'tool') {
863
- if (e && e.blocked === true) return { text: 'blocked', tone: 'bad' };
864
- if (e && e.ok === true) return { text: 'ok', tone: 'good' };
865
- if (e && e.ok === false) return { text: 'fail', tone: 'bad' };
866
- return { text: 'run', tone: '' };
867
- }
868
- // config / client / mcp / event - show the event name, neutral tone
869
- return { text: (e && e.event) || kind, tone: '' };
870
- }
871
-
872
- function renderLogEntry(e) {
873
- var kind = logKind(e);
874
- var st = logStatusOf(e, kind);
875
- var name = (e && (e.tool || e.name || e.id || e.client || e.path)) || '-';
876
-
877
- var head = el('div', { class: 'log-head' }, [
878
- el('span', { class: 'log-badge ' + kind, text: kind }),
879
- el('span', { class: 'log-name mono', text: name }),
880
- el('span', { class: 'badge ' + (st.tone === 'good' ? 'on' : st.tone === 'bad' ? 'bad' : 'off'), text: st.text }),
881
- el('span', { class: 'log-ts', text: (e && e.ts) || '' }),
882
- ]);
883
-
884
- // Secondary line: the most useful remaining field(s) for the kind.
885
- var detailText = '';
886
- if (kind === 'gate') {
887
- if (e && e.reason) detailText = String(e.reason);
888
- } else if (kind === 'tool') {
889
- var bits = [];
890
- if (e && e.mode) bits.push('mode: ' + e.mode);
891
- if (e && typeof e.durationMs === 'number') bits.push(e.durationMs + ' ms');
892
- detailText = bits.join(' · ');
893
- } else if (kind === 'auth') {
894
- var ab = [];
895
- if (e && e.error) ab.push(String(e.error));
896
- if (e && e.status) ab.push('HTTP ' + e.status);
897
- if (e && e.path) ab.push(e.path);
898
- detailText = ab.join(' · ');
899
- } else if (kind === 'config') {
900
- var cb = [];
901
- ['enabled', 'hidden', 'hot', 'action', 'mode', 'on', 'hook', 'issuer'].forEach(function (k) {
902
- if (e && e[k] !== undefined) cb.push(k + ': ' + e[k]);
903
- });
904
- if (e && e.via) cb.push('via ' + e.via);
905
- detailText = cb.join(' · ');
906
- } else {
907
- var eb = [];
908
- ['client', 'protocolVersion'].forEach(function (k) { if (e && e[k] !== undefined) eb.push(k + ': ' + e[k]); });
909
- detailText = eb.join(' · ');
910
- }
911
-
912
- var card = el('div', { class: 'row log-row' }, [head]);
913
- if (detailText) card.appendChild(el('p', { class: 'log-detail', text: detailText }));
914
- return card;
915
- }
916
-
917
- function renderLogEntries() {
918
- var list = $('#log-list');
919
- list.innerHTML = '';
920
- // Newest first - tail() returns file order (oldest->newest), so reverse for display.
921
- var rows = logEntries.slice().reverse();
922
- rows.forEach(function (e) { list.appendChild(renderLogEntry(e)); });
923
- var empty = $('#logs-empty');
924
- if (rows.length === 0) {
925
- empty.hidden = false;
926
- empty.innerHTML = '';
927
- if (!logConfig.enabled) {
928
- empty.appendChild(el('p', {}, ['Logging is off.']));
929
- empty.appendChild(el('p', { class: 'muted' }, ['Flip the switch above to start recording gate decisions and tool runs.']));
930
- } else {
931
- empty.appendChild(el('p', {}, ['No entries yet.']));
932
- empty.appendChild(el('p', { class: 'muted' }, ['Gate decisions and tool runs will appear here once activity is logged.']));
933
- }
934
- } else {
935
- empty.hidden = true;
936
- }
937
- $('#count-logs').textContent = rows.length ? String(rows.length) : '';
938
- }
939
-
940
- function loadLogEntries() {
941
- return getJson('/api/logs').then(function (data) {
942
- logEntries = (data && Array.isArray(data.entries)) ? data.entries : [];
943
- renderLogEntries();
944
- }).catch(function (e) {
945
- $('#log-list').innerHTML = '';
946
- logEntries = [];
947
- $('#logs-empty').hidden = false;
948
- $('#logs-empty').innerHTML = '';
949
- $('#logs-empty').appendChild(el('p', {}, ['Failed to load logs: ' + errText(e)]));
950
- });
951
- }
952
-
953
- function loadLogs() { return loadLogConfig().then(loadLogEntries); }
954
-
955
- /* ══════════════════════════════ AUTH (OAuth 2.1) ══════════════════════════════ */
956
- // Opt-in OAuth: the panel shows the optional jose dependency's install state + an Install button,
957
- // and an enable+config form. Reads /api/auth; writes /api/auth/config and /api/oauth/install. The
958
- // core gateway stays zero-dependency - jose is fetched only if the operator chooses to.
959
- var authState = { config: { enabled: false }, joseInstalled: false, josePin: 'jose', configError: null, ready: true };
960
-
961
- function renderAuth() {
962
- var c = authState.config || {};
963
- $('#auth-josepin').textContent = 'jose@' + (authState.josePin || '');
964
-
965
- // Dependency state + Install button.
966
- var depState = $('#auth-dep-state');
967
- var installBtn = $('#auth-install-btn');
968
- if (authState.joseInstalled) {
969
- depState.textContent = '✓ jose@' + (authState.josePin || '') + ' installed - OAuth is available';
970
- depState.className = 'auth-dep-state is-good';
971
- installBtn.hidden = true;
972
- } else {
973
- depState.textContent = '○ OAuth dependency not installed - the core gateway is zero-dependency until you add it';
974
- depState.className = 'auth-dep-state is-off';
975
- installBtn.hidden = false;
976
- }
977
-
978
- // Form fields.
979
- $('#auth-enabled').checked = !!c.enabled;
980
- $('#auth-issuer').value = c.issuer || '';
981
- $('#auth-audience').value = c.audience || '';
982
- $('#auth-jwksuri').value = c.jwksUri || '';
983
- $('#auth-algorithms').value = (Array.isArray(c.algorithms) ? c.algorithms : []).join(', ');
984
- $('#auth-scopes').value = (Array.isArray(c.requiredScopes) ? c.requiredScopes : []).join(', ');
985
- $('#auth-clock').value = (typeof c.clockToleranceSec === 'number') ? c.clockToleranceSec : '';
986
-
987
- // Ready / misconfigured badge - only meaningful when auth is enabled.
988
- var badge = $('#auth-ready-badge');
989
- if (c.enabled && !authState.ready) {
990
- badge.hidden = false;
991
- badge.className = 'auth-ready-badge is-bad';
992
- badge.textContent = '⚠ ' + (authState.configError || 'not ready - the HTTP host will refuse to start');
993
- } else if (c.enabled && authState.ready) {
994
- badge.hidden = false;
995
- badge.className = 'auth-ready-badge is-good';
996
- badge.textContent = '✓ active';
997
- } else {
998
- badge.hidden = true;
999
- }
1000
- }
1001
-
1002
- function loadAuth() {
1003
- return getJson('/api/auth').then(function (data) {
1004
- authState = {
1005
- config: (data && data.config) || { enabled: false },
1006
- joseInstalled: !!(data && data.joseInstalled),
1007
- josePin: (data && data.josePin) || '',
1008
- configError: (data && data.configError) || null,
1009
- ready: !(data && data.ready === false),
1010
- };
1011
- renderAuth();
1012
- }).catch(function (e) {
1013
- $('#auth-dep-state').textContent = 'Failed to load auth status: ' + errText(e);
1014
- });
1015
- }
1016
-
1017
- // Parse a comma/space-separated field into a trimmed, non-empty string array.
1018
- function splitList(v) {
1019
- return (v || '').split(/[\s,]+/).map(function (s) { return s.trim(); }).filter(Boolean);
1020
- }
1021
-
1022
- function submitAuth(ev) {
1023
- ev.preventDefault();
1024
- var patch = {
1025
- enabled: $('#auth-enabled').checked,
1026
- issuer: $('#auth-issuer').value.trim(),
1027
- audience: $('#auth-audience').value.trim(),
1028
- jwksUri: $('#auth-jwksuri').value.trim(),
1029
- algorithms: splitList($('#auth-algorithms').value),
1030
- requiredScopes: splitList($('#auth-scopes').value),
1031
- };
1032
- var clock = parseInt($('#auth-clock').value, 10);
1033
- if (!isNaN(clock) && clock >= 0) patch.clockToleranceSec = clock;
1034
-
1035
- var btn = $('#auth-save'); btn.disabled = true;
1036
- postJson('/api/auth/config', patch).then(function (res) {
1037
- btn.disabled = false;
1038
- if (!res || res.ok !== true) { toast((res && res.error) || 'Save failed', 'error'); return; }
1039
- toast(patch.enabled ? 'OAuth config saved - auth enabled' : 'OAuth config saved', 'good');
1040
- // Refresh from the server so the ready/misconfigured badge reflects the persisted state.
1041
- loadAuth();
1042
- }).catch(function (e) { btn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
1043
- }
1044
-
1045
- function installOauth() {
1046
- var btn = $('#auth-install-btn');
1047
- var logBox = $('#auth-install-log');
1048
- btn.disabled = true;
1049
- var label = btn.textContent; btn.textContent = 'Installing...';
1050
- logBox.hidden = false; logBox.textContent = 'Running npm install jose@' + (authState.josePin || '') + ' ...';
1051
- postJson('/api/oauth/install', {}).then(function (res) {
1052
- btn.disabled = false; btn.textContent = label;
1053
- var out = (res && (res.stderr || res.stdout)) || (res && res.message) || '';
1054
- logBox.textContent = (res && res.message ? res.message + '\n\n' : '') + out;
1055
- if (res && res.ok) {
1056
- toast('OAuth dependency installed - you can enable auth now', 'good');
1057
- loadAuth();
1058
- } else {
1059
- toast((res && res.message) || 'Install failed', 'error');
1060
- }
1061
- }).catch(function (e) {
1062
- btn.disabled = false; btn.textContent = label;
1063
- logBox.textContent = 'Install request failed: ' + errText(e);
1064
- toast('Install failed: ' + errText(e), 'error');
1065
- });
1066
- }
1067
-
1068
- /* ══════════════════════════════ Settings (identity & ports) ══════════════════════════════ */
1069
- // toolfunnel.json editor: server/client identity + default ports. Blank field = use the
1070
- // default (the server DELETES a blank field from the file). Changes take effect on the
1071
- // gateway's NEXT start - the note says so, no silent live-change expectation.
1072
- function loadIdentity() {
1073
- getJson('/api/identity').then(function (res) {
1074
- var f = (res && res.file) || {};
1075
- $('#id-servername').value = f.serverName || '';
1076
- $('#id-serverversion').value = f.serverVersion || '';
1077
- $('#id-clientname').value = f.clientName || '';
1078
- $('#id-clientversion').value = f.clientVersion || '';
1079
- $('#id-httpport').value = f.httpPort || '';
1080
- $('#id-uiport').value = f.uiPort || '';
1081
- $('#id-servelegacy').checked = f.serveLegacy !== false;
1082
- $('#identity-effective').textContent = res && res.effective
1083
- ? JSON.stringify(res.effective, null, 2) : '(unavailable)';
1084
- }).catch(function (e) { toast('Could not load settings: ' + errText(e), 'error'); });
1085
- }
1086
- function saveIdentity(ev) {
1087
- ev.preventDefault();
1088
- var body = {
1089
- serverName: $('#id-servername').value.trim(),
1090
- serverVersion: $('#id-serverversion').value.trim(),
1091
- clientName: $('#id-clientname').value.trim(),
1092
- clientVersion: $('#id-clientversion').value.trim(),
1093
- httpPort: $('#id-httpport').value.trim(),
1094
- uiPort: $('#id-uiport').value.trim(),
1095
- // checked = the serve-both default (field removed from the file); unchecked = explicit false.
1096
- serveLegacy: $('#id-servelegacy').checked ? '' : false,
1097
- };
1098
- var btn = $('#identity-save'); btn.disabled = true;
1099
- postJson('/api/identity', body).then(function (res) {
1100
- btn.disabled = false;
1101
- if (!res || res.ok !== true) { toast((res && res.error) || 'Save failed', 'error'); return; }
1102
- $('#identity-effective').textContent = res.effective ? JSON.stringify(res.effective, null, 2) : '·';
1103
- $('#identity-note').textContent = 'Saved - takes effect on the next gateway start.';
1104
- toast('Settings saved (restart to apply)', 'good');
1105
- }).catch(function (e) { btn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
1106
- }
1107
- $('#identity-form').addEventListener('submit', saveIdentity);
1108
-
1109
- /* ══════════════════════════════ tabs + wiring ══════════════════════════════ */
1110
- var loaded = { mcps: false, hooks: false, logs: false, auth: false, settings: false };
1111
- function switchTab(name) {
1112
- document.querySelectorAll('.tab').forEach(function (b) { b.classList.toggle('is-active', b.getAttribute('data-tab') === name); });
1113
- $('#view-tools').classList.toggle('is-active', name === 'tools');
1114
- $('#view-mcps').classList.toggle('is-active', name === 'mcps');
1115
- $('#view-hooks').classList.toggle('is-active', name === 'hooks');
1116
- $('#view-logs').classList.toggle('is-active', name === 'logs');
1117
- $('#view-auth').classList.toggle('is-active', name === 'auth');
1118
- $('#view-settings').classList.toggle('is-active', name === 'settings');
1119
- if (name === 'mcps' && !loaded.mcps) { loaded.mcps = true; loadMcps(); }
1120
- if (name === 'hooks' && !loaded.hooks) { loaded.hooks = true; loadHooks(); }
1121
- if (name === 'logs' && !loaded.logs) { loaded.logs = true; loadLogs(); }
1122
- if (name === 'auth' && !loaded.auth) { loaded.auth = true; loadAuth(); }
1123
- if (name === 'settings' && !loaded.settings) { loaded.settings = true; loadIdentity(); }
1124
- }
1125
-
1126
- // Populate the hook event dropdown.
1127
- EVENTS.forEach(function (e) { $('#h-event').appendChild(el('option', { value: e, text: e })); });
1128
- $('#h-event').value = EVENTS[2];
1129
-
1130
- document.querySelectorAll('.tab').forEach(function (b) {
1131
- b.addEventListener('click', function () { switchTab(b.getAttribute('data-tab')); });
1132
- });
1133
-
1134
- // Search inputs.
1135
- $('#search-tools').addEventListener('input', applyToolFilter);
1136
- $('#show-hidden').addEventListener('change', applyToolFilter);
1137
- $('#search-mcps').addEventListener('input', applyMcpFilter);
1138
- $('#search-hooks').addEventListener('input', applyHookFilter);
1139
-
1140
- // Add forms.
1141
- var toolForm = wireAddForm('#addbtn-tools', '#addform-tools', '#t-cancel', resetToolForm);
1142
- var mcpForm = wireAddForm('#addbtn-mcps', '#addform-mcps', '#m-cancel', resetMcpForm);
1143
- var hookForm = wireAddForm('#addbtn-hooks', '#addform-hooks', '#h-cancel', resetHookForm);
1144
-
1145
- $('#addform-tools').addEventListener('submit', submitTool);
1146
- $('#addform-mcps').addEventListener('submit', submitMcp);
1147
- $('#addform-hooks').addEventListener('submit', submitHook);
1148
- $('#t-mode').addEventListener('change', syncToolFields);
1149
- $('#t-invoke-type').addEventListener('change', syncToolFields);
1150
- $('#m-add-expose').addEventListener('click', addExposeRow);
1151
- syncToolFields();
1152
-
1153
- // Logs: bind the on/off switch to the config, and a manual refresh.
1154
- $('#log-enabled').addEventListener('change', function () {
1155
- var input = $('#log-enabled');
1156
- var want = input.checked;
1157
- driveSwitch(input, '/api/logs/config', { enabled: want },
1158
- function (res) {
1159
- if (res && res.config) {
1160
- logConfig = { enabled: !!res.config.enabled, path: res.config.path || logConfig.path };
1161
- } else {
1162
- logConfig.enabled = want;
1163
- }
1164
- updateLogStatus();
1165
- loadLogEntries();
1166
- },
1167
- function () { input.checked = !want; });
1168
- });
1169
- $('#logs-refresh').addEventListener('click', loadLogs);
1170
-
1171
- // Auth: the install button + the config form.
1172
- $('#auth-install-btn').addEventListener('click', installOauth);
1173
- $('#auth-form').addEventListener('submit', submitAuth);
1174
-
1175
- // Pre-seed the authored empty-state HTML BEFORE any load runs, so a first-load FAILURE (whose catch
1176
- // writes error text straight into the empty div, bypassing renderInto) can't get cached by a later
1177
- // renderInto as the canonical "nothing configured" message.
1178
- ['#tools-empty', '#mcps-empty', '#hooks-empty', '#logs-empty'].forEach(function (sel) {
1179
- var node = $(sel);
1180
- if (node && emptyOriginal[sel] === undefined) emptyOriginal[sel] = node.innerHTML;
1181
- });
1182
-
1183
- // Boot.
1184
- refreshStatus();
1185
- loadTools();
1186
- loadSurface();
1187
- })();
1
+ 'use strict';
2
+
3
+ /*
4
+ * ToolFunnel management console - vanilla client. Zero dependencies, zero external
5
+ * assets, works offline. Talks to the loopback JSON API served by src/ui/server.js.
6
+ * Every edit is persisted server-side through the gateway's own stores (registry,
7
+ * tool-state, expose-store, hook-loader), so the running MCP server sees changes
8
+ * with no restart.
9
+ *
10
+ * Four tabs - Tools / MCPs / Hooks / Logs. The first three each have a live search box,
11
+ * a collapsible Add form, and a list whose rows carry activate/deactivate + remove controls.
12
+ * Logs is read-mostly: an on/off toggle bound to the logger config plus a newest-first view
13
+ * of recent activity records.
14
+ */
15
+
16
+ (function () {
17
+ // ── tiny DOM helpers ────────────────────────────────────────────────────────
18
+ var $ = function (sel) { return document.querySelector(sel); };
19
+ function el(tag, attrs, children) {
20
+ var node = document.createElement(tag);
21
+ if (attrs) {
22
+ Object.keys(attrs).forEach(function (k) {
23
+ if (k === 'class') node.className = attrs[k];
24
+ else if (k === 'text') node.textContent = attrs[k];
25
+ else if (k === 'html') node.innerHTML = attrs[k];
26
+ else if (k.slice(0, 2) === 'on' && typeof attrs[k] === 'function') node.addEventListener(k.slice(2), attrs[k]);
27
+ else if (attrs[k] === true) node.setAttribute(k, '');
28
+ else if (attrs[k] != null && attrs[k] !== false) node.setAttribute(k, attrs[k]);
29
+ });
30
+ }
31
+ (children || []).forEach(function (c) {
32
+ if (c == null) return;
33
+ node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
34
+ });
35
+ return node;
36
+ }
37
+
38
+ // A server's text shown with its control and bidi characters visible, so it cannot reorder a line.
39
+ // Same escapes as the engine's printable(): a newline HTML would collapse stays visible.
40
+ var NAMED_ESCAPES = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
41
+ function printable(s) {
42
+ return String(s).replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, function (c) {
43
+ return NAMED_ESCAPES[c] || '\\u' + ('000' + c.charCodeAt(0).toString(16)).slice(-4);
44
+ });
45
+ }
46
+
47
+ // ── fetch wrappers ──────────────────────────────────────────────────────────
48
+ function getJson(url) {
49
+ return fetch(url, { headers: { Accept: 'application/json' } }).then(function (r) { return r.json(); });
50
+ }
51
+ function postJson(url, body) {
52
+ return fetch(url, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify(body),
56
+ }).then(function (r) { return r.json(); });
57
+ }
58
+
59
+ // ── toast ─────────────────────────────────────────────────────────────────--
60
+ var toastTimer = null;
61
+ function toast(msg, kind) {
62
+ var t = $('#toast');
63
+ t.innerHTML = '';
64
+ t.appendChild(typeof msg === 'string' ? document.createTextNode(msg) : msg);
65
+ t.className = 'toast show' + (kind === 'error' ? ' error' : kind === 'good' ? ' good' : '');
66
+ if (toastTimer) clearTimeout(toastTimer);
67
+ toastTimer = setTimeout(function () { t.className = 'toast'; }, 6000);
68
+ }
69
+ function errText(e) { return e && e.message ? e.message : String(e); }
70
+
71
+ // ── status counts ─────────────────────────────────────────────────────────--
72
+ function refreshStatus() {
73
+ return getJson('/api/status').then(function (s) {
74
+ $('#stat-tools').textContent = s && typeof s.tools === 'number' ? s.tools : '0';
75
+ $('#stat-upstreams').textContent = s && typeof s.upstreams === 'number' ? s.upstreams : '0';
76
+ $('#stat-hooks').textContent = s && typeof s.hooks === 'number' ? s.hooks : '0';
77
+ }).catch(function () { /* leave placeholders */ });
78
+ }
79
+
80
+ // ── shared controls ─────────────────────────────────────────────────────────
81
+ function makeSwitch(checked, small, onChange) {
82
+ var input = el('input', { type: 'checkbox' });
83
+ input.checked = !!checked;
84
+ input.addEventListener('change', function () { onChange(input); });
85
+ return el('label', { class: 'switch' + (small ? ' small' : '') }, [input, el('span', { class: 'slider' })]);
86
+ }
87
+ function toggleGroup(label, extraClass, sw) {
88
+ return el('div', { class: 'toggle-group ' + extraClass }, [sw, el('span', { class: 'toggle-label', text: label })]);
89
+ }
90
+ // A small destructive control with an inline confirm guard.
91
+ function removeButton(label, onConfirm) {
92
+ return el('button', {
93
+ class: 'btn-remove', type: 'button', title: 'Remove',
94
+ onclick: function () { if (window.confirm(label)) onConfirm(); },
95
+ }, ['Remove']);
96
+ }
97
+ // POST helper that drives a control disabled/revert/toast lifecycle. `apply` runs on success.
98
+ function driveSwitch(input, url, body, apply, onFail) {
99
+ input.disabled = true;
100
+ postJson(url, body).then(function (res) {
101
+ input.disabled = false;
102
+ if (!res || res.ok !== true) { onFail(); toast((res && res.error) || 'Action failed', 'error'); return; }
103
+ apply(res);
104
+ refreshStatus();
105
+ }).catch(function (e) { input.disabled = false; onFail(); toast('Request failed: ' + errText(e), 'error'); });
106
+ }
107
+
108
+ // ── generic filter ───────────────────────────────────────────────────────--
109
+ function filterList(items, query, hayFn) {
110
+ var q = (query || '').trim().toLowerCase();
111
+ if (!q) return items.slice();
112
+ return items.filter(function (it) { return hayFn(it).toLowerCase().indexOf(q) !== -1; });
113
+ }
114
+ // The authored innerHTML of each empty div (the rich "nothing configured" state) is
115
+ // captured once so it can be restored after a "no matches" message overwrote it.
116
+ var emptyOriginal = {};
117
+ function renderInto(listSel, emptySel, countSel, shown, total, renderFn, noMatchText) {
118
+ var list = $(listSel);
119
+ list.innerHTML = '';
120
+ shown.forEach(function (it) { list.appendChild(renderFn(it)); });
121
+ var empty = $(emptySel);
122
+ if (emptyOriginal[emptySel] === undefined) emptyOriginal[emptySel] = empty.innerHTML;
123
+ if (shown.length === 0) {
124
+ empty.hidden = false;
125
+ if (total === 0) empty.innerHTML = emptyOriginal[emptySel]; // nothing configured
126
+ else empty.textContent = noMatchText; // filtered to nothing
127
+ } else {
128
+ empty.hidden = true;
129
+ }
130
+ $(countSel).textContent = (shown.length !== total) ? (shown.length + '/' + total) : '';
131
+ }
132
+
133
+ // ── collapsible add forms ────────────────────────────────────────────────--
134
+ function wireAddForm(btnSel, formSel, cancelSel, onReset) {
135
+ var btn = $(btnSel), form = $(formSel);
136
+ function open(show) {
137
+ form.hidden = !show;
138
+ btn.setAttribute('aria-expanded', show ? 'true' : 'false');
139
+ btn.classList.toggle('is-open', show);
140
+ if (show) { var f = form.querySelector('input, select, textarea'); if (f) f.focus(); }
141
+ }
142
+ btn.addEventListener('click', function () { open(form.hidden); });
143
+ $(cancelSel).addEventListener('click', function () { onReset(); open(false); });
144
+ return { open: open };
145
+ }
146
+
147
+ /* ══════════════════════════════ TOOLS ══════════════════════════════ */
148
+ var tools = [];
149
+
150
+ function renderTool(t) {
151
+ var card = el('div', { class: 'row' + (t.enabled ? '' : ' is-disabled') + (t.hot ? ' is-hot' : ''), 'data-id': t.id });
152
+
153
+ var head = el('div', { class: 'row-head' }, [
154
+ el('span', { class: 'row-name', text: t.name || t.id }),
155
+ t.category ? el('span', { class: 'chip', text: t.category }) : null,
156
+ el('span', { class: 'row-id', text: t.id }),
157
+ ]);
158
+ var summary = el('p', { class: 'row-summary', text: t.summary || 'No summary.' });
159
+
160
+ // Execution-mode toggle (reference ↔ gateway). checked = gateway.
161
+ var modeSwitch = makeSwitch(t.mode === 'gateway', true, function (input) {
162
+ var want = input.checked ? 'gateway' : 'reference';
163
+ driveSwitch(input, '/api/tools/mode', { id: t.id, mode: want },
164
+ function (res) { t.mode = (res && res.mode) || want; modeLabel.textContent = t.mode; },
165
+ function () { input.checked = (t.mode === 'gateway'); });
166
+ });
167
+ var modeLabel = el('span', { class: 'toggle-label mode-label', text: t.mode || 'reference' });
168
+ var modeGroup = el('div', { class: 'toggle-group mode' }, [modeSwitch, modeLabel]);
169
+
170
+ // Pre / Post hook gates (POST /api/tools/hook). The switch reflects the tool's OWN literal
171
+ // entry (preOwn/postOwn), not "some broader hook fires" - a wildcard gate is not this
172
+ // switch's state, and rendering it as such made OFF toast a removal that never happened and
173
+ // then snap back on refresh. The server's stillGated/note is surfaced, and
174
+ // the panel re-fetches after every change instead of trusting the optimistic value.
175
+ function hookSwitch(eventName, label, current) {
176
+ return toggleGroup(label, 'hook', makeSwitch(current, true, function (input) {
177
+ var want = input.checked;
178
+ driveSwitch(input, '/api/tools/hook', { id: t.id, event: eventName, on: want },
179
+ function (res) {
180
+ if (res && res.note && res.stillGated) {
181
+ toast(el('span', {}, [label + ' gate: ', res.note]), 'warn');
182
+ } else if (want && res.scriptPath) {
183
+ toast(el('span', {}, [label + ' gate enabled for ', el('code', { text: t.id }), '. ', el('code', { text: res.scriptPath })]));
184
+ } else if (want) { toast(label + ' gate enabled for ' + t.id, 'good'); }
185
+ else { toast(label + ' gate removed for ' + t.id, 'good'); }
186
+ loadTools(); // re-render from the server's truth, never the optimistic flag
187
+ },
188
+ function () { input.checked = !want; });
189
+ }));
190
+ }
191
+
192
+ // Enable / disable - LEAN visibility (POST /api/tools/state).
193
+ var enableSwitch = makeSwitch(t.enabled, false, function (input) {
194
+ var want = input.checked;
195
+ driveSwitch(input, '/api/tools/state', { id: t.id, enabled: want },
196
+ // A disabled tool is never on the every-turn surface, so refresh the panel (count + warnings)
197
+ // - disabling a hot tool drops it from the surface, re-enabling restores it.
198
+ function () { t.enabled = want; card.classList.toggle('is-disabled', !want); loadSurface(); },
199
+ function () { input.checked = !want; });
200
+ });
201
+
202
+ // Hot - promote to the TOP-LEVEL every-turn surface (POST /api/tools/state {hot}). A hot tool is
203
+ // injected into the AI's context every turn AND becomes directly callable. Refresh the surface
204
+ // panel (count + bloat warnings) after a change.
205
+ var hotSwitch = makeSwitch(t.hot, false, function (input) {
206
+ var want = input.checked;
207
+ driveSwitch(input, '/api/tools/state', { id: t.id, hot: want },
208
+ function () { t.hot = want; card.classList.toggle('is-hot', want); loadSurface(); },
209
+ function () { input.checked = !want; });
210
+ });
211
+
212
+ // Hidden - declutter THIS manager list only (does not change what the AI sees). Re-filter on
213
+ // change so the row drops out when hidden (unless "show hidden" is on).
214
+ var hiddenSwitch = makeSwitch(t.hidden, false, function (input) {
215
+ var want = input.checked;
216
+ driveSwitch(input, '/api/tools/state', { id: t.id, hidden: want },
217
+ function () { t.hidden = want; applyToolFilter(); },
218
+ function () { input.checked = !want; });
219
+ });
220
+
221
+ // Details / edit - a lazy inline panel showing the full entry (instructions, invoke, script body)
222
+ // with editable fields + Save. Fetched from /api/tools/detail on first expand.
223
+ var detailPanel = el('div', { class: 'tool-detail', hidden: true });
224
+ var detailLoaded = false;
225
+ var detailsBtn = el('button', { class: 'btn btn-ghost btn-sm tool-details-btn', type: 'button' }, ['Details / edit']);
226
+ detailsBtn.addEventListener('click', function () {
227
+ var show = detailPanel.hidden;
228
+ detailPanel.hidden = !show;
229
+ detailsBtn.classList.toggle('is-open', show);
230
+ if (show && !detailLoaded) { detailLoaded = true; loadToolDetail(t, detailPanel); }
231
+ });
232
+
233
+ var controls = el('div', { class: 'row-controls' }, [
234
+ modeGroup,
235
+ el('div', { class: 'hook-toggles' }, [
236
+ hookSwitch('PreToolUse', 'Pre', t.preOwn === undefined ? t.pre : t.preOwn),
237
+ hookSwitch('PostToolUse', 'Post', t.postOwn === undefined ? t.post : t.postOwn),
238
+ // Covered by a broader hook the switch does not own: say so instead of faking the switch.
239
+ (t.pre && !t.preOwn) || (t.post && !t.postOwn)
240
+ ? el('span', { class: 'chip', title: 'A broader hook (wildcard or regex) also gates this tool. Manage it on the Hooks tab.', text: 'gated by broader hook' })
241
+ : null,
242
+ ]),
243
+ toggleGroup('Enabled', 'enable', enableSwitch),
244
+ toggleGroup('Hot', 'hot', hotSwitch),
245
+ toggleGroup('Hidden', 'hidden', hiddenSwitch),
246
+ detailsBtn,
247
+ removeButton('Remove tool "' + t.id + '" from the register?', function () {
248
+ postJson('/api/tools/remove', { id: t.id }).then(function (res) {
249
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Remove failed', 'error'); return; }
250
+ toast('Removed tool ' + t.id, 'good');
251
+ loadTools(); refreshStatus();
252
+ }).catch(function (e) { toast('Request failed: ' + errText(e), 'error'); });
253
+ }),
254
+ ]);
255
+
256
+ card.appendChild(head); card.appendChild(summary); card.appendChild(controls); card.appendChild(detailPanel);
257
+ return card;
258
+ }
259
+
260
+ // Fetch one tool's full detail and render the editor into `panel`.
261
+ function loadToolDetail(t, panel) {
262
+ panel.innerHTML = '';
263
+ panel.appendChild(el('p', { class: 'muted', text: 'Loading...' }));
264
+ getJson('/api/tools/detail?id=' + encodeURIComponent(t.id)).then(function (res) {
265
+ panel.innerHTML = '';
266
+ if (!res || res.ok !== true || !res.entry) {
267
+ panel.appendChild(el('p', { class: 'muted', text: (res && res.error) || 'Failed to load details.' }));
268
+ return;
269
+ }
270
+ renderToolEditor(t, panel, res.entry, res.scriptText);
271
+ }).catch(function (e) {
272
+ panel.innerHTML = '';
273
+ panel.appendChild(el('p', { class: 'muted', text: 'Failed to load details: ' + errText(e) }));
274
+ });
275
+ }
276
+
277
+ // Build the editable detail form for one tool. Save -> POST /api/tools/update.
278
+ function renderToolEditor(t, panel, entry, scriptText) {
279
+ var inv = entry.invoke || {};
280
+ function field(label, hint, control) {
281
+ return el('label', { class: 'field field-wide' }, [
282
+ el('span', {}, [label, hint ? el('em', { class: 'hint', text: ' ' + hint }) : null]),
283
+ control,
284
+ ]);
285
+ }
286
+ var fName = el('input', { type: 'text', value: entry.name || '' });
287
+ var fSummary = el('input', { type: 'text', value: entry.summary || '' });
288
+ var fCategory = el('input', { type: 'text', value: entry.category || '' });
289
+ var fMode = el('select', {}, [
290
+ el('option', { value: 'gateway', text: 'gateway - run here' }),
291
+ el('option', { value: 'reference', text: 'reference - AI runs it' }),
292
+ ]);
293
+ fMode.value = (t.mode === 'reference') ? 'reference' : 'gateway';
294
+ var fInstr = el('textarea', { rows: '4', spellcheck: 'false' }); fInstr.value = entry.instructions || '';
295
+
296
+ var fType = el('select', {}, [
297
+ el('option', { value: 'script', text: 'script' }),
298
+ el('option', { value: 'shell', text: 'shell' }),
299
+ el('option', { value: 'none', text: 'none (reference)' }),
300
+ ]);
301
+ fType.value = inv.type || 'none';
302
+ var fPath = el('input', { type: 'text', value: inv.path || '', spellcheck: 'false' });
303
+ var fCmd = el('input', { type: 'text', value: inv.command || '', spellcheck: 'false' });
304
+ var fScript = el('textarea', { rows: '8', spellcheck: 'false' }); fScript.value = scriptText || '';
305
+
306
+ var pathField = field('script path', '', fPath);
307
+ var cmdField = field('shell command', '', fCmd);
308
+ var scriptField = field('script body', '(authored under tools/scripts/)', fScript);
309
+ function syncInvoke() {
310
+ var ty = fType.value;
311
+ pathField.hidden = ty !== 'script';
312
+ cmdField.hidden = ty !== 'shell';
313
+ scriptField.hidden = ty !== 'script';
314
+ }
315
+ fType.addEventListener('change', syncInvoke);
316
+
317
+ var idTag = el('div', { class: 'tool-detail-id' }, [el('span', { class: 'row-id', text: 'id: ' + entry.id })]);
318
+ var grid = el('div', { class: 'field-grid' }, [
319
+ field('name', '', fName),
320
+ field('summary', '', fSummary),
321
+ field('category', '', fCategory),
322
+ field('mode', '', fMode),
323
+ field('invoke type', '', fType),
324
+ pathField, cmdField,
325
+ field('instructions', '', fInstr),
326
+ scriptField,
327
+ ]);
328
+
329
+ var saveBtn = el('button', { class: 'btn btn-primary', type: 'button' }, ['Save changes']);
330
+ saveBtn.addEventListener('click', function () {
331
+ var patch = {
332
+ name: fName.value.trim(),
333
+ summary: fSummary.value.trim(),
334
+ category: fCategory.value.trim(),
335
+ instructions: fInstr.value,
336
+ mode: fMode.value,
337
+ };
338
+ var ty = fType.value;
339
+ if (ty === 'script') { patch.invoke = { type: 'script', path: fPath.value.trim() }; }
340
+ else if (ty === 'shell') { patch.invoke = { type: 'shell', command: fCmd.value.trim() }; }
341
+ var body = { id: entry.id, patch: patch };
342
+ if (ty === 'script' && fScript.value.length) body.scriptText = fScript.value;
343
+ saveBtn.disabled = true;
344
+ postJson('/api/tools/update', body).then(function (res) {
345
+ saveBtn.disabled = false;
346
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Save failed', 'error'); return; }
347
+ toast('Saved ' + entry.id, 'good');
348
+ loadTools(); refreshStatus();
349
+ }).catch(function (e) { saveBtn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
350
+ });
351
+
352
+ panel.appendChild(idTag);
353
+ panel.appendChild(grid);
354
+ panel.appendChild(el('div', { class: 'addform-actions' }, [saveBtn]));
355
+ syncInvoke();
356
+ }
357
+
358
+ function applyToolFilter() {
359
+ // Hidden tools are decluttered from THIS manager list unless "show hidden" is ticked. (This is the
360
+ // manager view only - `hidden` never affects the lean list / top-level surface the AI sees.)
361
+ var showHidden = $('#show-hidden') && $('#show-hidden').checked;
362
+ var base = showHidden ? tools : tools.filter(function (t) { return !t.hidden; });
363
+ var shown = filterList(base, $('#search-tools').value, function (t) {
364
+ return t.id + ' ' + (t.name || '') + ' ' + (t.summary || '') + ' ' + (t.category || '') + ' ' + (t.mode || '');
365
+ });
366
+ // Distinguish "register empty" (tools.length===0 -> renderInto's authored message) from "all
367
+ // hidden" (tools exist but the hidden filter emptied the view) - pass tools.length as `total` so
368
+ // a non-empty register never reads as empty, and give an all-hidden-specific no-match message.
369
+ var noMatch = (base.length === 0 && tools.length > 0)
370
+ ? ('All ' + tools.length + ' tool' + (tools.length === 1 ? '' : 's') + ' are hidden - tick "show hidden" to see them.')
371
+ : 'No tools match your search.';
372
+ renderInto('#tool-list', '#tools-empty', '#count-tools', shown, tools.length, renderTool, noMatch);
373
+ }
374
+
375
+ function loadTools() {
376
+ return getJson('/api/tools').then(function (data) {
377
+ tools = Array.isArray(data) ? data : [];
378
+ applyToolFilter();
379
+ }).catch(function (e) {
380
+ $('#tool-list').innerHTML = '';
381
+ $('#tools-empty').hidden = false;
382
+ $('#tools-empty').textContent = 'Failed to load tools: ' + errText(e);
383
+ });
384
+ }
385
+
386
+ /* ── Top-level surface panel ─────────────────────────────────────────────────
387
+ * Shows which tools are injected EVERY turn: the 4 meta-tools (default on, each
388
+ * toggleable), the count of promoted tools, and footgun warnings (hiding the
389
+ * management tools / promoting too many = context bloat). Reads /api/surface. */
390
+ function loadSurface() {
391
+ return getJson('/api/surface').then(renderSurface).catch(function () { /* leave last state */ });
392
+ }
393
+ function renderSurface(data) {
394
+ var meta = (data && Array.isArray(data.meta)) ? data.meta : [];
395
+ var metaBox = $('#surface-meta');
396
+ metaBox.innerHTML = '';
397
+ meta.forEach(function (m) {
398
+ var sw = makeSwitch(m.hot, true, function (input) {
399
+ var want = input.checked;
400
+ driveSwitch(input, '/api/tools/state', { id: m.name, hot: want },
401
+ function () { m.hot = want; loadSurface(); },
402
+ function () { input.checked = !want; });
403
+ });
404
+ metaBox.appendChild(el('div', { class: 'surface-meta-item' + (m.hot ? '' : ' is-off') }, [sw, el('code', { text: m.name })]));
405
+ });
406
+ var total = (data && typeof data.promotedTotal === 'number') ? data.promotedTotal : 0;
407
+ var countEl = $('#surface-count');
408
+ countEl.textContent = total ? (total + ' promoted') : 'lean';
409
+ countEl.className = 'surface-count' + (total > 10 ? ' is-bloat' : total ? ' is-some' : '');
410
+
411
+ var warnBox = $('#surface-warnings');
412
+ var warnings = (data && Array.isArray(data.warnings)) ? data.warnings : [];
413
+ warnBox.innerHTML = '';
414
+ if (warnings.length) {
415
+ warnings.forEach(function (w) { warnBox.appendChild(el('p', { class: 'surface-warning', text: '⚠ ' + w })); });
416
+ warnBox.hidden = false;
417
+ } else {
418
+ warnBox.hidden = true;
419
+ }
420
+ }
421
+
422
+ function resetToolForm() {
423
+ ['#t-id', '#t-name', '#t-category', '#t-summary', '#t-path', '#t-command', '#t-instructions', '#t-scripttext']
424
+ .forEach(function (s) { $(s).value = ''; });
425
+ $('#t-mode').value = 'gateway';
426
+ $('#t-invoke-type').value = 'script';
427
+ syncToolFields();
428
+ }
429
+ // Show/hide invoke fields by mode + invoke type.
430
+ function syncToolFields() {
431
+ var mode = $('#t-mode').value;
432
+ var type = $('#t-invoke-type').value;
433
+ var ref = mode === 'reference';
434
+ $('#t-invoke-type-field').hidden = ref;
435
+ $('#t-path-field').hidden = ref || type !== 'script';
436
+ $('#t-command-field').hidden = ref || type !== 'shell';
437
+ $('#t-script-field').hidden = ref || type !== 'script';
438
+ }
439
+
440
+ function submitTool(ev) {
441
+ ev.preventDefault();
442
+ var id = $('#t-id').value.trim(), name = $('#t-name').value.trim();
443
+ if (!id) { toast('Tool id is required', 'error'); return; }
444
+ if (!name) { toast('Tool name is required', 'error'); return; }
445
+ var mode = $('#t-mode').value;
446
+ var entry = { id: id, name: name, mode: mode };
447
+ var v;
448
+ if ((v = $('#t-summary').value.trim())) entry.summary = v;
449
+ if ((v = $('#t-category').value.trim())) entry.category = v;
450
+ if ((v = $('#t-instructions').value.trim())) entry.instructions = v;
451
+ if (mode !== 'reference') {
452
+ var type = $('#t-invoke-type').value;
453
+ if (type === 'script') {
454
+ var path = $('#t-path').value.trim();
455
+ if (!path) { toast('Script path is required for a script invoke', 'error'); return; }
456
+ entry.invoke = { type: 'script', path: path };
457
+ var body = $('#t-scripttext').value;
458
+ if (body && body.length) entry.scriptText = body;
459
+ } else if (type === 'shell') {
460
+ var cmd = $('#t-command').value.trim();
461
+ if (!cmd) { toast('Shell command is required for a shell invoke', 'error'); return; }
462
+ entry.invoke = { type: 'shell', command: cmd };
463
+ }
464
+ }
465
+ var submit = $('#t-submit'); submit.disabled = true;
466
+ postJson('/api/tools/add', { entry: entry }).then(function (res) {
467
+ submit.disabled = false;
468
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Add failed', 'error'); return; }
469
+ toast('Added tool ' + id, 'good');
470
+ resetToolForm(); toolForm.open(false);
471
+ loadTools(); refreshStatus();
472
+ }).catch(function (e) { submit.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
473
+ }
474
+
475
+ /* ══════════════════════════════ MCPs ══════════════════════════════ */
476
+ var upstreams = [];
477
+ var exposeByUpstream = {};
478
+ var wrapping = null; // the active passthrough wrap (upstream id) or null
479
+
480
+ function renderUpstream(u) {
481
+ var exposed = exposeByUpstream[u.id] || [];
482
+ var isWrapped = wrapping === u.id;
483
+ var card = el('div', { class: 'row' + (u.enabled ? '' : ' is-disabled'), 'data-id': u.id });
484
+
485
+ var head = el('div', { class: 'row-head' }, [
486
+ el('span', { class: 'row-name mono', text: u.id }),
487
+ el('span', { class: 'badge', text: u.transport || 'stdio' }),
488
+ el('span', { class: 'badge ' + (u.enabled ? 'on' : 'off'), text: u.enabled ? 'enabled' : 'disabled' }),
489
+ u.legacyPin === true ? el('span', { class: 'badge off', text: 'legacy-pinned (MCP 2024-11-05)' }) : null,
490
+ isWrapped ? el('span', { class: 'badge on', text: 'WRAPPED - this MCP is the whole surface' }) : null,
491
+ ]);
492
+ var cmd = el('p', { class: 'row-cmd', text: [u.command].concat(u.args || []).join(' ') });
493
+ var desc = u.description ? el('p', { class: 'row-summary', text: u.description }) : null;
494
+
495
+ var exposedRows = exposed.length
496
+ ? exposed.map(function (e) {
497
+ return el('div', { class: 'exposed-row' }, [
498
+ el('span', { class: 'tool', text: e.tool }),
499
+ el('span', { class: 'arrow', text: '->' }),
500
+ el('span', { class: 'as', text: e.as || (u.id + '_' + e.tool) }),
501
+ e.category ? el('span', { class: 'chip', text: e.category }) : null,
502
+ el('span', { class: 'badge ' + (e.enabled ? 'on' : 'off'), text: e.enabled ? 'on' : 'off' }),
503
+ ]);
504
+ })
505
+ : [el('div', { class: 'exposed-row' }, [el('span', { class: 'none', text: 'No exposed tools.' })])];
506
+ var exposedBlock = el('div', { class: 'exposed' }, [el('p', { class: 'exposed-title', text: 'Exposed tools' })].concat(exposedRows));
507
+
508
+ var enableSwitch = makeSwitch(u.enabled, false, function (input) {
509
+ var want = input.checked;
510
+ driveSwitch(input, '/api/mcp/state', { id: u.id, action: want ? 'enable' : 'disable' },
511
+ function () { u.enabled = want; card.classList.toggle('is-disabled', !want); loadMcps(); },
512
+ function () { input.checked = !want; });
513
+ });
514
+
515
+ // legacyPin: keep speaking MCP 2024-11-05 to this upstream forever - never auto-
516
+ // upgrades, warns at startup and per call. Opt-in; takes effect on the next (re)connect.
517
+ var pinSwitch = makeSwitch(u.legacyPin === true, true, function (input) {
518
+ var want = input.checked;
519
+ driveSwitch(input, '/api/mcp/state', { id: u.id, action: want ? 'pin' : 'unpin' },
520
+ function () { u.legacyPin = want; loadMcps(); },
521
+ function () { input.checked = !want; });
522
+ });
523
+
524
+ // Discovered-tools area (filled by the Discover button) - a LIVE connect + tools/list, then a
525
+ // per-tool Lean (enabled) + Hot toggle keyed by each tool's SURFACED name (what the gateway reads).
526
+ var discovered = el('div', { class: 'discovered', hidden: true });
527
+ var discoverBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button', title: 'Connect to this upstream and list its tools' }, ['Discover tools']);
528
+ discoverBtn.addEventListener('click', function () {
529
+ discoverBtn.disabled = true;
530
+ var label = discoverBtn.textContent; discoverBtn.textContent = 'Discovering...';
531
+ postJson('/api/mcp/discover', { id: u.id }).then(function (res) {
532
+ discoverBtn.disabled = false; discoverBtn.textContent = label;
533
+ if (!res || res.ok !== true) {
534
+ toast((res && res.error) || 'Discover failed', 'error');
535
+ renderDiscovered(discovered, []);
536
+ discovered.hidden = false;
537
+ return;
538
+ }
539
+ var n = res.tools ? res.tools.length : 0;
540
+ toast(n + ' tool' + (n === 1 ? '' : 's') + ' discovered on ' + u.id, 'good');
541
+ renderDiscovered(discovered, res.tools || []);
542
+ discovered.hidden = false;
543
+ }).catch(function (e) { discoverBtn.disabled = false; discoverBtn.textContent = label; toast('Request failed: ' + errText(e), 'error'); });
544
+ });
545
+
546
+ // Wrap / Unwrap - the passthrough control. Wrapping makes THIS upstream's tools the entire
547
+ // MCP surface (original names); everything else, meta-tools included, disappears until
548
+ // unwrapped. This button is one of the two recovery paths from an AI-set wrap (the other is
549
+ // `toolfunnel wrap --off`), so it must always be visible and obvious.
550
+ var wrapBtn = el('button', {
551
+ class: 'btn btn-sm ' + (isWrapped ? 'btn-danger' : 'btn-ghost'),
552
+ type: 'button',
553
+ title: isWrapped
554
+ ? 'Restore the normal ToolFunnel surface (meta-tools + matrix)'
555
+ : 'Make this MCP’s tools the ENTIRE surface (transparent wrapper - hides all ToolFunnel tools)',
556
+ }, [isWrapped ? 'Unwrap' : 'Wrap']);
557
+ wrapBtn.addEventListener('click', function () {
558
+ if (!isWrapped) {
559
+ var ok = window.confirm(
560
+ 'Wrap "' + u.id + '"?\n\nIts tools become the ENTIRE MCP surface, under their original names. ' +
561
+ 'ALL ToolFunnel tools (meta-tools included) become hidden and uncallable until you unwrap - ' +
562
+ 'here, or with: toolfunnel wrap --off\n\nEvery call still passes the PreToolUse gate.'
563
+ );
564
+ if (!ok) return;
565
+ }
566
+ wrapBtn.disabled = true;
567
+ postJson('/api/wrap', { upstream: isWrapped ? null : u.id }).then(function (res) {
568
+ wrapBtn.disabled = false;
569
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Wrap change failed', 'error'); return; }
570
+ toast(isWrapped ? 'Unwrapped - normal surface restored' : 'Wrapped ' + u.id + ' - its tools are now the whole surface', 'good');
571
+ // Same informed consent the CLI gives: outside paths are PERMITTED under a wrap (the
572
+ // isolation guard is suspended for this upstream) - say so, prominently.
573
+ if (res.warning) window.alert(res.warning);
574
+ loadMcps(); refreshStatus();
575
+ }).catch(function (e) { wrapBtn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
576
+ });
577
+
578
+ var controls = el('div', { class: 'row-controls' }, [
579
+ toggleGroup('Enabled', 'enable', enableSwitch),
580
+ toggleGroup('Legacy pin', 'pin', pinSwitch),
581
+ discoverBtn,
582
+ wrapBtn,
583
+ removeButton('Remove upstream "' + u.id + '" (and its exposed tools)?', function () {
584
+ postJson('/api/mcp/state', { id: u.id, action: 'remove' }).then(function (res) {
585
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Remove failed', 'error'); return; }
586
+ toast('Removed upstream ' + u.id, 'good');
587
+ loadMcps(); refreshStatus();
588
+ }).catch(function (e) { toast('Request failed: ' + errText(e), 'error'); });
589
+ }),
590
+ ]);
591
+
592
+ card.appendChild(head);
593
+ if (desc) card.appendChild(desc);
594
+ card.appendChild(cmd);
595
+ card.appendChild(exposedBlock);
596
+ card.appendChild(controls);
597
+ card.appendChild(discovered);
598
+ return card;
599
+ }
600
+
601
+ // Render the LIVE-discovered tools of one upstream, each with a Lean (enabled) + Hot toggle keyed
602
+ // by its SURFACED name (an enabled expose `as` else `<upstream>_<tool>`) - the exact key the running
603
+ // gateway reads, so a toggle here is byte-identical to editing tools.state.json.
604
+ function renderDiscovered(container, tools) {
605
+ container.innerHTML = '';
606
+ if (!tools.length) {
607
+ container.appendChild(el('p', { class: 'none', text: 'No tools discovered (the upstream did not connect, or advertises none).' }));
608
+ return;
609
+ }
610
+ container.appendChild(el('p', { class: 'exposed-title', text: 'Discovered tools - curate lean visibility / promote hot' }));
611
+ tools.forEach(function (t) {
612
+ var leanSwitch = makeSwitch(t.enabled, true, function (input) {
613
+ var want = input.checked;
614
+ driveSwitch(input, '/api/tools/state', { id: t.name, enabled: want },
615
+ // Disabling a hot upstream tool drops it from the surface too - refresh the panel.
616
+ function () { t.enabled = want; loadSurface(); }, function () { input.checked = !want; });
617
+ });
618
+ var hotSwitch = makeSwitch(t.hot, true, function (input) {
619
+ var want = input.checked;
620
+ driveSwitch(input, '/api/tools/state', { id: t.name, hot: want },
621
+ function () { t.hot = want; loadSurface(); }, function () { input.checked = !want; });
622
+ });
623
+ container.appendChild(el('div', { class: 'discovered-row' }, [
624
+ el('span', { class: 'tool mono', text: t.tool }),
625
+ el('span', { class: 'arrow', text: '->' }),
626
+ el('span', { class: 'as mono', text: t.name }),
627
+ t.description ? el('span', { class: 'disc-desc', text: t.description }) : null,
628
+ toggleGroup('Lean', 'lean', leanSwitch),
629
+ toggleGroup('Hot', 'hot', hotSwitch),
630
+ ]));
631
+ });
632
+ }
633
+
634
+ function applyMcpFilter() {
635
+ var shown = filterList(upstreams, $('#search-mcps').value, function (u) {
636
+ return u.id + ' ' + (u.command || '') + ' ' + (u.args || []).join(' ') + ' ' + (u.description || '');
637
+ });
638
+ renderInto('#mcp-list', '#mcps-empty', '#count-mcps', shown, upstreams.length, renderUpstream, 'No upstreams match your search.');
639
+ }
640
+
641
+ // A loud, always-visible banner while a passthrough wrap is active - this page is the visual
642
+ // recovery path from a wrap (the funnel's own tools are hidden from the MCP surface, so an
643
+ // attached AI cannot undo it in-band; the human can, here, with one click).
644
+ function renderWrapBanner() {
645
+ var existing = document.getElementById('wrap-banner');
646
+ if (existing) existing.remove();
647
+ if (!wrapping) return;
648
+ var list = $('#mcp-list');
649
+ if (!list || !list.parentNode) return;
650
+ var unwrapBtn = el('button', { class: 'btn btn-sm', type: 'button' }, ['Unwrap now']);
651
+ unwrapBtn.addEventListener('click', function () {
652
+ unwrapBtn.disabled = true;
653
+ postJson('/api/wrap', { upstream: null }).then(function (res) {
654
+ unwrapBtn.disabled = false;
655
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Unwrap failed', 'error'); return; }
656
+ toast('Unwrapped - normal surface restored', 'good');
657
+ loadMcps(); refreshStatus();
658
+ }).catch(function (e) { unwrapBtn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
659
+ });
660
+ var banner = el('div', { id: 'wrap-banner', class: 'row' }, [
661
+ el('p', { text: 'PASSTHROUGH ACTIVE: the gateway is wrapping "' + wrapping + '" - its tools are the entire ' +
662
+ 'MCP surface (original names). All ToolFunnel tools are hidden from connected clients. Every call is still gated.' }),
663
+ unwrapBtn,
664
+ ]);
665
+ list.parentNode.insertBefore(banner, list);
666
+ }
667
+
668
+ // Tool descriptor changes held back for acceptance. Descriptions are the SERVER's own,
669
+ // unverified text: rendered with textContent only (el's `text`), never as HTML.
670
+ function renderToolChanges(data) {
671
+ var existing = document.getElementById('tool-changes');
672
+ if (existing) existing.remove();
673
+ var list = $('#mcp-list');
674
+ if (!list || !list.parentNode) return;
675
+ if (data && data.ok === false) {
676
+ list.parentNode.insertBefore(el('div', { id: 'tool-changes', class: 'row' }, [
677
+ el('p', { text: 'TOOL APPROVALS UNREADABLE: ' + (data.error || 'unknown error') + ' - the gateway withholds the ' +
678
+ 'affected tools until the file is repaired (removing it, or an upstream\'s entry, approves the current tools as first use).' }),
679
+ ]), list);
680
+ return;
681
+ }
682
+ var pending = (data && Array.isArray(data.pending)) ? data.pending : [];
683
+ if (!pending.length) return;
684
+ var box = el('div', { id: 'tool-changes' }, [
685
+ el('p', { text: pending.length + ' tool change(s) held back: the server changed what these tools say without ' +
686
+ 'changing its version (or strictApproval is on). Until you accept, the gateway keeps showing the approved text ' +
687
+ 'and refuses calls to them. Accept only if you recognise the change.' }),
688
+ ]);
689
+ pending.forEach(function (p) {
690
+ var acceptBtn = el('button', { class: 'btn btn-sm', type: 'button' }, ['Accept']);
691
+ acceptBtn.addEventListener('click', function () {
692
+ acceptBtn.disabled = true;
693
+ postJson('/api/tool-changes/accept', { upstream: p.upstream, tool: p.tool, hash: p.hash }).then(function (res) {
694
+ acceptBtn.disabled = false;
695
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Accept failed', 'error'); loadToolChanges(); return; }
696
+ toast('Accepted ' + p.upstream + ' / ' + printable(p.tool), 'good');
697
+ loadToolChanges();
698
+ }).catch(function (e) { acceptBtn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
699
+ });
700
+ box.appendChild(el('div', { class: 'row' }, [
701
+ el('p', { text: p.upstream + ' / ' + printable(p.tool) + ' (' + (p.reason === 'new' ? 'new tool' : 'changed: ' + p.changed.join(', ')) + ')' }),
702
+ el('p', { class: 'muted', text: 'Approved: ' + (p.approvedDescription == null ? '(none - a new tool)' : printable(p.approvedDescription)) }),
703
+ el('p', { text: 'New (the server\'s text): ' + (p.newDescription == null ? '(no description)' : printable(p.newDescription)) }),
704
+ el('div', {}, Object.keys(p.fields || {}).map(function (f) {
705
+ return el('p', { style: 'font-family: var(--mono)', text: 'New ' + f + ': ' + printable(JSON.stringify(p.fields[f].new)) });
706
+ })),
707
+ acceptBtn,
708
+ ]));
709
+ });
710
+ list.parentNode.insertBefore(box, list);
711
+ }
712
+
713
+ function loadToolChanges() {
714
+ return getJson('/api/tool-changes').then(renderToolChanges).catch(function () { /* the list below still loads */ });
715
+ }
716
+
717
+ function loadMcps() {
718
+ loadToolChanges();
719
+ return getJson('/api/upstreams').then(function (data) {
720
+ upstreams = (data && Array.isArray(data.upstreams)) ? data.upstreams : [];
721
+ var expose = (data && Array.isArray(data.expose)) ? data.expose : [];
722
+ wrapping = (data && typeof data.wrapping === 'string' && data.wrapping) ? data.wrapping : null;
723
+ exposeByUpstream = {};
724
+ expose.forEach(function (e) { (exposeByUpstream[e.upstream] = exposeByUpstream[e.upstream] || []).push(e); });
725
+ renderWrapBanner();
726
+ applyMcpFilter();
727
+ }).catch(function (e) {
728
+ $('#mcp-list').innerHTML = '';
729
+ $('#mcps-empty').hidden = false;
730
+ $('#mcps-empty').textContent = 'Failed to load upstreams: ' + errText(e);
731
+ });
732
+ }
733
+
734
+ // Dynamic expose-entry rows in the MCP add form.
735
+ function addExposeRow() {
736
+ var row = el('div', { class: 'expose-input-row' }, [
737
+ el('input', { type: 'text', class: 'x-tool', placeholder: 'tool', spellcheck: 'false' }),
738
+ el('input', { type: 'text', class: 'x-as', placeholder: 'as (optional)', spellcheck: 'false' }),
739
+ el('input', { type: 'text', class: 'x-cat', placeholder: 'category', spellcheck: 'false' }),
740
+ el('button', { type: 'button', class: 'btn-remove btn-sm', title: 'Remove row', onclick: function () { row.remove(); } }, ['×']),
741
+ ]);
742
+ $('#m-expose-rows').appendChild(row);
743
+ }
744
+ function collectExpose() {
745
+ var out = [];
746
+ $('#m-expose-rows').querySelectorAll('.expose-input-row').forEach(function (r) {
747
+ var tool = r.querySelector('.x-tool').value.trim();
748
+ if (!tool) return;
749
+ var item = { tool: tool };
750
+ var as = r.querySelector('.x-as').value.trim(); if (as) item.as = as;
751
+ var cat = r.querySelector('.x-cat').value.trim(); if (cat) item.category = cat;
752
+ out.push(item);
753
+ });
754
+ return out;
755
+ }
756
+ function resetMcpForm() {
757
+ ['#m-id', '#m-command', '#m-args', '#m-description'].forEach(function (s) { $(s).value = ''; });
758
+ $('#m-transport').value = 'stdio';
759
+ $('#m-expose-rows').innerHTML = '';
760
+ }
761
+
762
+ function submitMcp(ev) {
763
+ ev.preventDefault();
764
+ var id = $('#m-id').value.trim(), command = $('#m-command').value.trim();
765
+ if (!id) { toast('Upstream id is required', 'error'); return; }
766
+ if (!command) { toast('Command is required', 'error'); return; }
767
+ var argsRaw = $('#m-args').value.trim();
768
+ var upstream = { id: id, command: command, transport: $('#m-transport').value || 'stdio' };
769
+ if (argsRaw) upstream.args = argsRaw.split(/\s+/);
770
+ var desc = $('#m-description').value.trim(); if (desc) upstream.description = desc;
771
+ var payload = { upstream: upstream };
772
+ var expose = collectExpose();
773
+ if (expose.length) payload.expose = expose;
774
+ var submit = $('#m-submit'); submit.disabled = true;
775
+ postJson('/api/mcp/add', payload).then(function (res) {
776
+ submit.disabled = false;
777
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Add failed', 'error'); return; }
778
+ toast('Added upstream ' + id, 'good');
779
+ resetMcpForm(); mcpForm.open(false);
780
+ loadMcps(); refreshStatus();
781
+ }).catch(function (e) { submit.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
782
+ }
783
+
784
+ /* ══════════════════════════════ HOOKS ══════════════════════════════ */
785
+ var EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'PreCompact'];
786
+ var hooks = [];
787
+
788
+ function renderHook(h) {
789
+ var hasId = !!h.id;
790
+ var card = el('div', { class: 'row' + (h.enabled ? '' : ' is-disabled'), 'data-id': h.id || '' });
791
+
792
+ var head = el('div', { class: 'row-head' }, [
793
+ el('span', { class: 'row-name mono', text: h.id || '(tool gate)' }),
794
+ el('span', { class: 'chip', text: h.event || '?' }),
795
+ hasId ? null : el('span', { class: 'badge', text: 'auto' }),
796
+ ]);
797
+ var meta = el('p', { class: 'row-cmd', text: 'matcher: ' + (h.matcher && h.matcher.length ? h.matcher : '* (all)') });
798
+ var desc = h.description ? el('p', { class: 'row-summary', text: h.description }) : null;
799
+
800
+ var controls;
801
+ if (hasId) {
802
+ var enableSwitch = makeSwitch(h.enabled, false, function (input) {
803
+ var want = input.checked;
804
+ driveSwitch(input, '/api/hooks/state', { id: h.id, action: want ? 'enable' : 'disable' },
805
+ function () { h.enabled = want; card.classList.toggle('is-disabled', !want); },
806
+ function () { input.checked = !want; });
807
+ });
808
+ controls = el('div', { class: 'row-controls' }, [
809
+ toggleGroup('Enabled', 'enable', enableSwitch),
810
+ removeButton('Remove hook "' + h.id + '"? (its script file is left in place)', function () {
811
+ postJson('/api/hooks/state', { id: h.id, action: 'remove' }).then(function (res) {
812
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Remove failed', 'error'); return; }
813
+ toast('Removed hook ' + h.id, 'good');
814
+ loadHooks(); refreshStatus();
815
+ }).catch(function (e) { toast('Request failed: ' + errText(e), 'error'); });
816
+ }),
817
+ ]);
818
+ } else {
819
+ // Tool Pre/Post gate (no id) - managed from the Tools tab; show state read-only.
820
+ controls = el('div', { class: 'row-controls' }, [
821
+ el('span', { class: 'badge ' + (h.enabled ? 'on' : 'off'), text: h.enabled ? 'enabled' : 'disabled' }),
822
+ el('span', { class: 'managed-note', text: 'managed on Tools tab' }),
823
+ ]);
824
+ }
825
+
826
+ card.appendChild(head);
827
+ if (desc) card.appendChild(desc);
828
+ card.appendChild(meta);
829
+ card.appendChild(controls);
830
+ return card;
831
+ }
832
+
833
+ function applyHookFilter() {
834
+ var shown = filterList(hooks, $('#search-hooks').value, function (h) {
835
+ return (h.id || '') + ' ' + (h.event || '') + ' ' + (h.matcher || '') + ' ' + (h.description || '');
836
+ });
837
+ renderInto('#hook-list', '#hooks-empty', '#count-hooks', shown, hooks.length, renderHook, 'No hooks match your search.');
838
+ }
839
+
840
+ function loadHooks() {
841
+ return getJson('/api/hooks').then(function (data) {
842
+ hooks = (data && Array.isArray(data.hooks)) ? data.hooks : [];
843
+ applyHookFilter();
844
+ }).catch(function (e) {
845
+ $('#hook-list').innerHTML = '';
846
+ $('#hooks-empty').hidden = false;
847
+ $('#hooks-empty').textContent = 'Failed to load hooks: ' + errText(e);
848
+ });
849
+ }
850
+
851
+ function resetHookForm() {
852
+ ['#h-id', '#h-matcher', '#h-command', '#h-scripttext'].forEach(function (s) { $(s).value = ''; });
853
+ $('#h-event').value = EVENTS[2]; // PreToolUse default
854
+ }
855
+
856
+ function submitHook(ev) {
857
+ ev.preventDefault();
858
+ var id = $('#h-id').value.trim(), command = $('#h-command').value.trim();
859
+ if (!id) { toast('Hook id is required', 'error'); return; }
860
+ if (!command) { toast('Command is required', 'error'); return; }
861
+ var entry = { id: id, event: $('#h-event').value, command: command };
862
+ var matcher = $('#h-matcher').value.trim(); if (matcher) entry.matcher = matcher;
863
+ var body = $('#h-scripttext').value; if (body && body.length) entry.scriptText = body;
864
+ var submit = $('#h-submit'); submit.disabled = true;
865
+ postJson('/api/hooks/add', { entry: entry }).then(function (res) {
866
+ submit.disabled = false;
867
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Add failed', 'error'); return; }
868
+ toast('Added hook ' + id + ' (disabled - enable it to fire)', 'good');
869
+ resetHookForm(); hookForm.open(false);
870
+ loadHooks(); refreshStatus();
871
+ }).catch(function (e) { submit.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
872
+ }
873
+
874
+ /* ══════════════════════════════ LOGS ══════════════════════════════ */
875
+ // The activity log is a toggleable JSONL store (DEFAULT OFF). This tab binds an on/off
876
+ // switch to /api/logs/config and shows the most recent records newest-first.
877
+ var logConfig = { enabled: false, path: '' };
878
+ var logEntries = [];
879
+
880
+ // Reflect the current config in the switch, status word, and (when on) the log path.
881
+ function updateLogStatus() {
882
+ $('#log-enabled').checked = !!logConfig.enabled;
883
+ var status = $('#log-status');
884
+ status.textContent = logConfig.enabled ? 'logging is on' : 'logging is off';
885
+ status.className = 'log-status' + (logConfig.enabled ? ' on' : '');
886
+ $('#log-path').textContent = (logConfig.enabled && logConfig.path) ? logConfig.path : '';
887
+ }
888
+
889
+ function loadLogConfig() {
890
+ return getJson('/api/logs/config').then(function (cfg) {
891
+ logConfig = {
892
+ enabled: !!(cfg && cfg.enabled),
893
+ path: (cfg && typeof cfg.path === 'string') ? cfg.path : '',
894
+ };
895
+ updateLogStatus();
896
+ }).catch(function () {
897
+ logConfig = { enabled: false, path: '' };
898
+ updateLogStatus();
899
+ });
900
+ }
901
+
902
+ // Classify a record for its small type badge. The gateway emits type:'gate' / type:'tool';
903
+ // fall back defensively (a record carrying a `decision` is a gate, else a tool).
904
+ function logKind(e) {
905
+ var t = e && e.type;
906
+ if (t === 'gate' || t === 'tool' || t === 'config' || t === 'auth' || t === 'client' || t === 'mcp') return t;
907
+ if (e && e.decision != null) return 'gate';
908
+ if (e && (e.tool || e.ok != null)) return 'tool';
909
+ return 'event';
910
+ }
911
+ // The status word + tone per kind: gate -> allow/deny, tool -> ok/fail (blocked counts as fail).
912
+ function logStatusOf(e, kind) {
913
+ if (kind === 'gate') {
914
+ var d = (e && e.decision) || '?';
915
+ return { text: d, tone: d === 'deny' ? 'bad' : (d === 'allow' ? 'good' : '') };
916
+ }
917
+ if (kind === 'auth') {
918
+ var ev = (e && e.event) || '?';
919
+ return { text: ev, tone: ev === 'deny' ? 'bad' : (ev === 'allow' ? 'good' : '') };
920
+ }
921
+ if (kind === 'tool') {
922
+ if (e && e.blocked === true) return { text: 'blocked', tone: 'bad' };
923
+ if (e && e.ok === true) return { text: 'ok', tone: 'good' };
924
+ if (e && e.ok === false) return { text: 'fail', tone: 'bad' };
925
+ return { text: 'run', tone: '' };
926
+ }
927
+ // config / client / mcp / event - show the event name, neutral tone
928
+ return { text: (e && e.event) || kind, tone: '' };
929
+ }
930
+
931
+ function renderLogEntry(e) {
932
+ var kind = logKind(e);
933
+ var st = logStatusOf(e, kind);
934
+ var name = (e && (e.tool || e.name || e.id || e.client || e.path)) || '-';
935
+
936
+ var head = el('div', { class: 'log-head' }, [
937
+ el('span', { class: 'log-badge ' + kind, text: kind }),
938
+ el('span', { class: 'log-name mono', text: name }),
939
+ el('span', { class: 'badge ' + (st.tone === 'good' ? 'on' : st.tone === 'bad' ? 'bad' : 'off'), text: st.text }),
940
+ el('span', { class: 'log-ts', text: (e && e.ts) || '' }),
941
+ ]);
942
+
943
+ // Secondary line: the most useful remaining field(s) for the kind.
944
+ var detailText = '';
945
+ if (kind === 'gate') {
946
+ if (e && e.reason) detailText = String(e.reason);
947
+ } else if (kind === 'tool') {
948
+ var bits = [];
949
+ if (e && e.mode) bits.push('mode: ' + e.mode);
950
+ if (e && typeof e.durationMs === 'number') bits.push(e.durationMs + ' ms');
951
+ detailText = bits.join(' · ');
952
+ } else if (kind === 'auth') {
953
+ var ab = [];
954
+ if (e && e.error) ab.push(String(e.error));
955
+ if (e && e.status) ab.push('HTTP ' + e.status);
956
+ if (e && e.path) ab.push(e.path);
957
+ detailText = ab.join(' · ');
958
+ } else if (kind === 'config') {
959
+ var cb = [];
960
+ ['enabled', 'hidden', 'hot', 'action', 'mode', 'on', 'hook', 'issuer'].forEach(function (k) {
961
+ if (e && e[k] !== undefined) cb.push(k + ': ' + e[k]);
962
+ });
963
+ if (e && e.via) cb.push('via ' + e.via);
964
+ detailText = cb.join(' · ');
965
+ } else {
966
+ var eb = [];
967
+ ['client', 'protocolVersion'].forEach(function (k) { if (e && e[k] !== undefined) eb.push(k + ': ' + e[k]); });
968
+ detailText = eb.join(' · ');
969
+ }
970
+
971
+ var card = el('div', { class: 'row log-row' }, [head]);
972
+ if (detailText) card.appendChild(el('p', { class: 'log-detail', text: detailText }));
973
+ return card;
974
+ }
975
+
976
+ function renderLogEntries() {
977
+ var list = $('#log-list');
978
+ list.innerHTML = '';
979
+ // Newest first - tail() returns file order (oldest->newest), so reverse for display.
980
+ var rows = logEntries.slice().reverse();
981
+ rows.forEach(function (e) { list.appendChild(renderLogEntry(e)); });
982
+ var empty = $('#logs-empty');
983
+ if (rows.length === 0) {
984
+ empty.hidden = false;
985
+ empty.innerHTML = '';
986
+ if (!logConfig.enabled) {
987
+ empty.appendChild(el('p', {}, ['Logging is off.']));
988
+ empty.appendChild(el('p', { class: 'muted' }, ['Flip the switch above to start recording gate decisions and tool runs.']));
989
+ } else {
990
+ empty.appendChild(el('p', {}, ['No entries yet.']));
991
+ empty.appendChild(el('p', { class: 'muted' }, ['Gate decisions and tool runs will appear here once activity is logged.']));
992
+ }
993
+ } else {
994
+ empty.hidden = true;
995
+ }
996
+ $('#count-logs').textContent = rows.length ? String(rows.length) : '';
997
+ }
998
+
999
+ function loadLogEntries() {
1000
+ return getJson('/api/logs').then(function (data) {
1001
+ logEntries = (data && Array.isArray(data.entries)) ? data.entries : [];
1002
+ renderLogEntries();
1003
+ }).catch(function (e) {
1004
+ $('#log-list').innerHTML = '';
1005
+ logEntries = [];
1006
+ $('#logs-empty').hidden = false;
1007
+ $('#logs-empty').innerHTML = '';
1008
+ $('#logs-empty').appendChild(el('p', {}, ['Failed to load logs: ' + errText(e)]));
1009
+ });
1010
+ }
1011
+
1012
+ function loadLogs() { return loadLogConfig().then(loadLogEntries); }
1013
+
1014
+ /* ══════════════════════════════ AUTH (OAuth 2.1) ══════════════════════════════ */
1015
+ // Opt-in OAuth: the panel shows the optional jose dependency's install state + an Install button,
1016
+ // and an enable+config form. Reads /api/auth; writes /api/auth/config and /api/oauth/install. The
1017
+ // core gateway stays zero-dependency - jose is fetched only if the operator chooses to.
1018
+ var authState = { config: { enabled: false }, joseInstalled: false, josePin: 'jose', configError: null, ready: true };
1019
+
1020
+ function renderAuth() {
1021
+ var c = authState.config || {};
1022
+ $('#auth-josepin').textContent = 'jose@' + (authState.josePin || '');
1023
+
1024
+ // Dependency state + Install button.
1025
+ var depState = $('#auth-dep-state');
1026
+ var installBtn = $('#auth-install-btn');
1027
+ if (authState.joseInstalled) {
1028
+ depState.textContent = '✓ jose@' + (authState.josePin || '') + ' installed - OAuth is available';
1029
+ depState.className = 'auth-dep-state is-good';
1030
+ installBtn.hidden = true;
1031
+ } else {
1032
+ depState.textContent = '○ OAuth dependency not installed - the core gateway is zero-dependency until you add it';
1033
+ depState.className = 'auth-dep-state is-off';
1034
+ installBtn.hidden = false;
1035
+ }
1036
+
1037
+ // Form fields.
1038
+ $('#auth-enabled').checked = !!c.enabled;
1039
+ $('#auth-issuer').value = c.issuer || '';
1040
+ $('#auth-audience').value = c.audience || '';
1041
+ $('#auth-jwksuri').value = c.jwksUri || '';
1042
+ $('#auth-algorithms').value = (Array.isArray(c.algorithms) ? c.algorithms : []).join(', ');
1043
+ $('#auth-scopes').value = (Array.isArray(c.requiredScopes) ? c.requiredScopes : []).join(', ');
1044
+ $('#auth-clock').value = (typeof c.clockToleranceSec === 'number') ? c.clockToleranceSec : '';
1045
+
1046
+ // Ready / misconfigured badge - only meaningful when auth is enabled.
1047
+ var badge = $('#auth-ready-badge');
1048
+ if (c.enabled && !authState.ready) {
1049
+ badge.hidden = false;
1050
+ badge.className = 'auth-ready-badge is-bad';
1051
+ badge.textContent = '⚠ ' + (authState.configError || 'not ready - the HTTP host will refuse to start');
1052
+ } else if (c.enabled && authState.ready) {
1053
+ badge.hidden = false;
1054
+ badge.className = 'auth-ready-badge is-good';
1055
+ badge.textContent = '✓ active';
1056
+ } else {
1057
+ badge.hidden = true;
1058
+ }
1059
+ }
1060
+
1061
+ function loadAuth() {
1062
+ return getJson('/api/auth').then(function (data) {
1063
+ authState = {
1064
+ config: (data && data.config) || { enabled: false },
1065
+ joseInstalled: !!(data && data.joseInstalled),
1066
+ josePin: (data && data.josePin) || '',
1067
+ configError: (data && data.configError) || null,
1068
+ ready: !(data && data.ready === false),
1069
+ };
1070
+ renderAuth();
1071
+ }).catch(function (e) {
1072
+ $('#auth-dep-state').textContent = 'Failed to load auth status: ' + errText(e);
1073
+ });
1074
+ }
1075
+
1076
+ // Parse a comma/space-separated field into a trimmed, non-empty string array.
1077
+ function splitList(v) {
1078
+ return (v || '').split(/[\s,]+/).map(function (s) { return s.trim(); }).filter(Boolean);
1079
+ }
1080
+
1081
+ function submitAuth(ev) {
1082
+ ev.preventDefault();
1083
+ var patch = {
1084
+ enabled: $('#auth-enabled').checked,
1085
+ issuer: $('#auth-issuer').value.trim(),
1086
+ audience: $('#auth-audience').value.trim(),
1087
+ jwksUri: $('#auth-jwksuri').value.trim(),
1088
+ algorithms: splitList($('#auth-algorithms').value),
1089
+ requiredScopes: splitList($('#auth-scopes').value),
1090
+ };
1091
+ var clock = parseInt($('#auth-clock').value, 10);
1092
+ if (!isNaN(clock) && clock >= 0) patch.clockToleranceSec = clock;
1093
+
1094
+ var btn = $('#auth-save'); btn.disabled = true;
1095
+ postJson('/api/auth/config', patch).then(function (res) {
1096
+ btn.disabled = false;
1097
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Save failed', 'error'); return; }
1098
+ toast(patch.enabled ? 'OAuth config saved - auth enabled' : 'OAuth config saved', 'good');
1099
+ // Refresh from the server so the ready/misconfigured badge reflects the persisted state.
1100
+ loadAuth();
1101
+ }).catch(function (e) { btn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
1102
+ }
1103
+
1104
+ function installOauth() {
1105
+ var btn = $('#auth-install-btn');
1106
+ var logBox = $('#auth-install-log');
1107
+ btn.disabled = true;
1108
+ var label = btn.textContent; btn.textContent = 'Installing...';
1109
+ logBox.hidden = false; logBox.textContent = 'Running npm install jose@' + (authState.josePin || '') + ' ...';
1110
+ postJson('/api/oauth/install', {}).then(function (res) {
1111
+ btn.disabled = false; btn.textContent = label;
1112
+ var out = (res && (res.stderr || res.stdout)) || (res && res.message) || '';
1113
+ logBox.textContent = (res && res.message ? res.message + '\n\n' : '') + out;
1114
+ if (res && res.ok) {
1115
+ toast('OAuth dependency installed - you can enable auth now', 'good');
1116
+ loadAuth();
1117
+ } else {
1118
+ toast((res && res.message) || 'Install failed', 'error');
1119
+ }
1120
+ }).catch(function (e) {
1121
+ btn.disabled = false; btn.textContent = label;
1122
+ logBox.textContent = 'Install request failed: ' + errText(e);
1123
+ toast('Install failed: ' + errText(e), 'error');
1124
+ });
1125
+ }
1126
+
1127
+ /* ══════════════════════════════ Settings (identity & ports) ══════════════════════════════ */
1128
+ // toolfunnel.json editor: server/client identity + default ports. Blank field = use the
1129
+ // default (the server DELETES a blank field from the file). Changes take effect on the
1130
+ // gateway's NEXT start - the note says so, no silent live-change expectation.
1131
+ function loadIdentity() {
1132
+ getJson('/api/identity').then(function (res) {
1133
+ var f = (res && res.file) || {};
1134
+ $('#id-servername').value = f.serverName || '';
1135
+ $('#id-serverversion').value = f.serverVersion || '';
1136
+ $('#id-clientname').value = f.clientName || '';
1137
+ $('#id-clientversion').value = f.clientVersion || '';
1138
+ $('#id-httpport').value = f.httpPort || '';
1139
+ $('#id-uiport').value = f.uiPort || '';
1140
+ $('#id-servelegacy').checked = f.serveLegacy !== false;
1141
+ $('#identity-effective').textContent = res && res.effective
1142
+ ? JSON.stringify(res.effective, null, 2) : '(unavailable)';
1143
+ }).catch(function (e) { toast('Could not load settings: ' + errText(e), 'error'); });
1144
+ }
1145
+ function saveIdentity(ev) {
1146
+ ev.preventDefault();
1147
+ var body = {
1148
+ serverName: $('#id-servername').value.trim(),
1149
+ serverVersion: $('#id-serverversion').value.trim(),
1150
+ clientName: $('#id-clientname').value.trim(),
1151
+ clientVersion: $('#id-clientversion').value.trim(),
1152
+ httpPort: $('#id-httpport').value.trim(),
1153
+ uiPort: $('#id-uiport').value.trim(),
1154
+ // checked = the serve-both default (field removed from the file); unchecked = explicit false.
1155
+ serveLegacy: $('#id-servelegacy').checked ? '' : false,
1156
+ };
1157
+ var btn = $('#identity-save'); btn.disabled = true;
1158
+ postJson('/api/identity', body).then(function (res) {
1159
+ btn.disabled = false;
1160
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Save failed', 'error'); return; }
1161
+ $('#identity-effective').textContent = res.effective ? JSON.stringify(res.effective, null, 2) : '·';
1162
+ $('#identity-note').textContent = 'Saved - takes effect on the next gateway start.';
1163
+ toast('Settings saved (restart to apply)', 'good');
1164
+ }).catch(function (e) { btn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
1165
+ }
1166
+ $('#identity-form').addEventListener('submit', saveIdentity);
1167
+
1168
+ /* ══════════════════════════════ tabs + wiring ══════════════════════════════ */
1169
+ var loaded = { mcps: false, hooks: false, logs: false, auth: false, settings: false };
1170
+ function switchTab(name) {
1171
+ document.querySelectorAll('.tab').forEach(function (b) { b.classList.toggle('is-active', b.getAttribute('data-tab') === name); });
1172
+ $('#view-tools').classList.toggle('is-active', name === 'tools');
1173
+ $('#view-mcps').classList.toggle('is-active', name === 'mcps');
1174
+ $('#view-hooks').classList.toggle('is-active', name === 'hooks');
1175
+ $('#view-logs').classList.toggle('is-active', name === 'logs');
1176
+ $('#view-auth').classList.toggle('is-active', name === 'auth');
1177
+ $('#view-settings').classList.toggle('is-active', name === 'settings');
1178
+ if (name === 'mcps' && !loaded.mcps) { loaded.mcps = true; loadMcps(); }
1179
+ if (name === 'hooks' && !loaded.hooks) { loaded.hooks = true; loadHooks(); }
1180
+ if (name === 'logs' && !loaded.logs) { loaded.logs = true; loadLogs(); }
1181
+ if (name === 'auth' && !loaded.auth) { loaded.auth = true; loadAuth(); }
1182
+ if (name === 'settings' && !loaded.settings) { loaded.settings = true; loadIdentity(); }
1183
+ }
1184
+
1185
+ // Populate the hook event dropdown.
1186
+ EVENTS.forEach(function (e) { $('#h-event').appendChild(el('option', { value: e, text: e })); });
1187
+ $('#h-event').value = EVENTS[2];
1188
+
1189
+ document.querySelectorAll('.tab').forEach(function (b) {
1190
+ b.addEventListener('click', function () { switchTab(b.getAttribute('data-tab')); });
1191
+ });
1192
+
1193
+ // Search inputs.
1194
+ $('#search-tools').addEventListener('input', applyToolFilter);
1195
+ $('#show-hidden').addEventListener('change', applyToolFilter);
1196
+ $('#search-mcps').addEventListener('input', applyMcpFilter);
1197
+ $('#search-hooks').addEventListener('input', applyHookFilter);
1198
+
1199
+ // Add forms.
1200
+ var toolForm = wireAddForm('#addbtn-tools', '#addform-tools', '#t-cancel', resetToolForm);
1201
+ var mcpForm = wireAddForm('#addbtn-mcps', '#addform-mcps', '#m-cancel', resetMcpForm);
1202
+ var hookForm = wireAddForm('#addbtn-hooks', '#addform-hooks', '#h-cancel', resetHookForm);
1203
+
1204
+ $('#addform-tools').addEventListener('submit', submitTool);
1205
+ $('#addform-mcps').addEventListener('submit', submitMcp);
1206
+ $('#addform-hooks').addEventListener('submit', submitHook);
1207
+ $('#t-mode').addEventListener('change', syncToolFields);
1208
+ $('#t-invoke-type').addEventListener('change', syncToolFields);
1209
+ $('#m-add-expose').addEventListener('click', addExposeRow);
1210
+ syncToolFields();
1211
+
1212
+ // Logs: bind the on/off switch to the config, and a manual refresh.
1213
+ $('#log-enabled').addEventListener('change', function () {
1214
+ var input = $('#log-enabled');
1215
+ var want = input.checked;
1216
+ driveSwitch(input, '/api/logs/config', { enabled: want },
1217
+ function (res) {
1218
+ if (res && res.config) {
1219
+ logConfig = { enabled: !!res.config.enabled, path: res.config.path || logConfig.path };
1220
+ } else {
1221
+ logConfig.enabled = want;
1222
+ }
1223
+ updateLogStatus();
1224
+ loadLogEntries();
1225
+ },
1226
+ function () { input.checked = !want; });
1227
+ });
1228
+ $('#logs-refresh').addEventListener('click', loadLogs);
1229
+
1230
+ // Auth: the install button + the config form.
1231
+ $('#auth-install-btn').addEventListener('click', installOauth);
1232
+ $('#auth-form').addEventListener('submit', submitAuth);
1233
+
1234
+ // Pre-seed the authored empty-state HTML BEFORE any load runs, so a first-load FAILURE (whose catch
1235
+ // writes error text straight into the empty div, bypassing renderInto) can't get cached by a later
1236
+ // renderInto as the canonical "nothing configured" message.
1237
+ ['#tools-empty', '#mcps-empty', '#hooks-empty', '#logs-empty'].forEach(function (sel) {
1238
+ var node = $(sel);
1239
+ if (node && emptyOriginal[sel] === undefined) emptyOriginal[sel] = node.innerHTML;
1240
+ });
1241
+
1242
+ // Boot.
1243
+ refreshStatus();
1244
+ loadTools();
1245
+ loadSurface();
1246
+ })();