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