toolfunnel 0.4.0 → 0.6.0

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