toolfunnel 0.6.0 → 0.7.1

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