toolfunnel 0.3.0 → 0.5.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 (40) hide show
  1. package/README.md +167 -324
  2. package/bin/toolfunnel.js +39 -4
  3. package/docs/KNOWN_BUGS.md +33 -0
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/packaging.md +115 -0
  6. package/package.json +3 -1
  7. package/src/auth/config.js +4 -2
  8. package/src/auth/resource-server.js +28 -3
  9. package/src/core/config-home.js +134 -0
  10. package/src/core/logger.js +4 -2
  11. package/src/core/requires.js +138 -0
  12. package/src/core/server-config.js +77 -0
  13. package/src/extend/create-tool.md +24 -4
  14. package/src/extend/package.md +33 -0
  15. package/src/mcp/aggregator.js +12 -7
  16. package/src/mcp/http-transport.js +71 -21
  17. package/src/mcp/mcp-client.js +9 -3
  18. package/src/mcp/server.js +181 -16
  19. package/src/packages/packager.js +5 -3
  20. package/src/tools/registry.js +47 -2
  21. package/src/ui/server.js +49 -3
  22. package/tools/scripts/tf-env.js +43 -0
  23. package/tools/scripts/tf-hook-add.js +4 -5
  24. package/tools/scripts/tf-hook-set.js +4 -5
  25. package/tools/scripts/tf-list.js +9 -12
  26. package/tools/scripts/tf-log.js +4 -1
  27. package/tools/scripts/tf-mcp-add.js +4 -6
  28. package/tools/scripts/tf-mcp-set.js +4 -6
  29. package/tools/scripts/tf-pack.js +237 -0
  30. package/tools/scripts/tf-tool-add.js +16 -16
  31. package/tools/scripts/tf-tool-set.js +5 -11
  32. package/tools/tools.register.json +46 -1
  33. package/docs/admin-ui.md +0 -404
  34. package/docs/configuration.md +0 -402
  35. package/docs/hooks-and-gating.md +0 -428
  36. package/docs/logging.md +0 -223
  37. package/docs/management.md +0 -429
  38. package/docs/oauth.md +0 -103
  39. package/docs/setup.md +0 -352
  40. package/docs/tools-and-upstreams.md +0 -469
package/src/mcp/server.js CHANGED
@@ -53,7 +53,18 @@ const logger = require('../core/logger');
53
53
  const metrics = require('../core/metrics');
54
54
 
55
55
  // ── Path anchors (everything stays under the host root) ───────────────────────────────────────
56
- const ROOT = path.resolve(__dirname, '..', '..'); // <…>/<root>
56
+ // The CODE lives in the package; the mutable CONFIG lives in the resolved config home — the same
57
+ // directory in a git clone, distinct when TOOLFUNNEL_HOME / --config-dir points elsewhere (npm
58
+ // installs, npm-wrapped MCPs — an `npm update` must never eat the user's tools). Every anchor
59
+ // below derives from the HOME.
60
+ const { PKG_ROOT, resolveConfigHome, ensureConfigHome } = require('../core/config-home');
61
+ const ROOT = resolveConfigHome(); // <…>/<config home>
62
+ // The process-tree contract for child tools (defaultRunScript spawns with process.env): a
63
+ // management script SEEDED into an external home finds the engine code via TOOLFUNNEL_PKG, and
64
+ // the resolved ABSOLUTE home is written back so no child re-resolves a relative value against a
65
+ // different cwd.
66
+ process.env.TOOLFUNNEL_PKG = PKG_ROOT;
67
+ if (ROOT !== PKG_ROOT) process.env.TOOLFUNNEL_HOME = ROOT;
57
68
  const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
58
69
  // The ACTIVE/DISABLED overlay (tool-state.js). toolfunnel_list_tools filters DISABLED tools out so
59
70
  // they are not surfaced to the client. Read FRESH per list() call so UI toggles take effect with no
@@ -68,7 +79,14 @@ const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
68
79
  const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
69
80
 
70
81
  const PROTOCOL_VERSION = '2024-11-05';
71
- const SERVER_INFO = { name: 'toolfunnel', version: '0.3.0' };
82
+ // Identity comes from the OPTIONAL toolfunnel.json at the root (absent → name "toolfunnel",
83
+ // version from package.json — serverInfo can never drift from the released version). The config
84
+ // seam is what lets a wrapped MCP introduce itself as ITSELF in the initialize handshake and
85
+ // /health. Loaded once at module init: clients cache serverInfo from initialize, so identity is
86
+ // deliberately NOT hot-reloaded (a mid-session identity swap would lie to connected clients).
87
+ const { loadServerConfig } = require('../core/server-config');
88
+ const SERVER_CONFIG = loadServerConfig(ROOT);
89
+ const SERVER_INFO = { name: SERVER_CONFIG.serverName, version: SERVER_CONFIG.serverVersion };
72
90
 
73
91
  // ── Diagnostics → stderr only (stdout is the JSON-RPC channel; never pollute it). ─────────────
74
92
  function logErr(...parts) {
@@ -198,7 +216,9 @@ function makeRegistryAdapter(registry, opts) {
198
216
  if (!desc) return null;
199
217
 
200
218
  // Reference mode: nothing executes here. Hand back the instructions (no execute thunk)
201
- // so protocol.runTool short-circuits BEFORE gatedRun — no spawn, no gate.
219
+ // so protocol.runTool takes the no-spawn path (execute() is never called). Note this is
220
+ // NOT ungated: protocol.runTool still fires PreToolUse on the instruction handoff, so a
221
+ // deny withholds the instructions. What's absent is server-side EXECUTION, not the gate.
202
222
  if (desc.mode === 'reference') {
203
223
  return {
204
224
  mode: 'reference',
@@ -248,6 +268,11 @@ function makeRegistryAdapter(registry, opts) {
248
268
  // - engine, ctx: threaded into gatedRun for the curated-direct path (a curated tool runs
249
269
  // THROUGH the same PreToolUse gate as toolfunnel_run_tool — the safety invariant).
250
270
  function buildProtocol() {
271
+ // Make the config home real before anything reads from it: seeds an external home's register/
272
+ // scripts/expose/hooks from the shipped defaults on first use. A no-op when home === package
273
+ // root; idempotent and never-overwriting afterwards. A failure falls through to loadRegistry's
274
+ // own clear error.
275
+ try { ensureConfigHome(ROOT); } catch (err) { logErr('config-home init failed:', (err && err.message) || String(err)); }
251
276
  const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
252
277
  const loader = loadManifest(MANIFEST_PATH);
253
278
 
@@ -911,6 +936,10 @@ function emitToolsListChanged(send) {
911
936
  * @returns {Promise<void>}
912
937
  */
913
938
  async function reloadExpose(build, send) {
939
+ // NEVER-throws contract. A null/absent build means the caller (e.g. the HTTP host mid-teardown,
940
+ // which nulls its build) has moved on — do nothing rather than deref null. (reloadHooks/
941
+ // reloadRegister already guard this; expose was the one unguarded reloader — a proven crash.)
942
+ if (!build || typeof build !== 'object') return;
914
943
  let store;
915
944
  try {
916
945
  store = loadExposeStore(EXPOSE_PATH);
@@ -930,12 +959,20 @@ async function reloadExpose(build, send) {
930
959
  } catch (err) {
931
960
  logErr('reloadExpose: connectAll threw (ignored):', (err && err.message) || String(err));
932
961
  }
933
- const prev = build.aggregator;
934
- build.aggregator = next; // swap BEFORE closing the old one (handleMessage reads build fresh)
935
- if (prev && typeof prev.closeAll === 'function') {
936
- try { await prev.closeAll(); } catch (_e) { /* closeAll never throws, but guard teardown */ }
962
+ // The swap/close/notify is guarded too: even a surprise (a build torn down DURING the async
963
+ // connectAll above) must be a caught no-op, not an escaped rejection that crashes the host.
964
+ try {
965
+ const prev = build.aggregator;
966
+ build.aggregator = next; // swap BEFORE closing the old one (handleMessage reads build fresh)
967
+ if (prev && typeof prev.closeAll === 'function') {
968
+ try { await prev.closeAll(); } catch (_e) { /* closeAll never throws, but guard teardown */ }
969
+ }
970
+ emitToolsListChanged(send);
971
+ } catch (err) {
972
+ logErr('reloadExpose: swap failed (keeping last-good):', (err && err.message) || String(err));
973
+ try { await next.closeAll(); } catch (_e) { /* orphan-avoidance; never throw */ }
974
+ return;
937
975
  }
938
- emitToolsListChanged(send);
939
976
  // Activity log (self-gating): mark the reload trigger; the per-upstream connect/disconnect lines
940
977
  // (from the aggregator) carry the detail. Together they record the reconnect.
941
978
  logger.log({
@@ -947,6 +984,31 @@ async function reloadExpose(build, send) {
947
984
  logErr('reloadExpose: expose.json reloaded — upstreams reconnected');
948
985
  }
949
986
 
987
+ /**
988
+ * Re-read tools.register.json and swap the ONE live Registry's index IN PLACE (Registry.reload —
989
+ * last-good on any failure). In-place mutation is the point: the Registry instance is CAPTURED by
990
+ * the protocol adapter at build time, so swapping internals means every reader — the lean
991
+ * meta-tools, run_tool resolution, and the top-level hot surface — sees the new entries on its
992
+ * next call with no rewiring. Emits list_changed: a hot-promoted local tool's definition may have
993
+ * appeared, changed, or gone. NEVER throws.
994
+ *
995
+ * @param {{registry?:object}} build
996
+ * @param {(obj:object)=>void} send the stdio writer, for the list_changed notification
997
+ * @returns {void}
998
+ */
999
+ function reloadRegister(build, send) {
1000
+ try {
1001
+ if (build && build.registry && typeof build.registry.reload === 'function' && build.registry.reload()) {
1002
+ emitToolsListChanged(send);
1003
+ logErr('reloadRegister: tools.register.json reloaded');
1004
+ } else {
1005
+ logErr('reloadRegister: reload failed (bad JSON / invalid entry / mid-write?) — keeping the last-good register');
1006
+ }
1007
+ } catch (err) {
1008
+ logErr('reloadRegister: unexpected failure (keeping current register):', (err && err.message) || String(err));
1009
+ }
1010
+ }
1011
+
950
1012
  /**
951
1013
  * Re-read the hooks manifest + state overlay and swap a fresh HookEngine into `build`. The next
952
1014
  * gated call (toolfunnel_run_tool / curated-direct) uses the new engine, so an added/changed/toggled
@@ -966,20 +1028,34 @@ function reloadHooks(build) {
966
1028
  }
967
1029
 
968
1030
  /**
969
- * Watch the config directories and hot-reload on change. Watches the DIRECTORIES (robust to the
970
- * atomic temp+rename writes expose-store / hook-loader use, which replace the file inode) and
971
- * filters by basename. Debounced (a temp+rename burst → one reload), with a re-entrancy guard so a
972
- * mid-flight expose reload can't overlap itself. A watch failure (an fs that doesn't support
973
- * fs.watch) is logged and ignored — the server still runs, just without auto-reload. NEVER throws.
1031
+ * Watch the config and hot-reload on change. TWO mechanisms, belt-and-braces:
1032
+ * 1. fs.watch on the DIRECTORIES — fast, event-driven, filtered by basename.
1033
+ * 2. fs.watchFile POLLING on each specific config file — the reliable fallback.
1034
+ * The directory watch alone is NOT enough: fs.watch is event-based and on Windows frequently
1035
+ * MISSES the atomic temp+rename writes the stores use (the target's inode is replaced without a
1036
+ * usable change event on the directory), so a `tf_hook_set` / UI toggle could fail to hot-reload
1037
+ * until restart. fs.watchFile polls each file's stat, so it catches every create/modify (including
1038
+ * atomic rename) on every platform. Both paths feed the SAME debounced schedulers, so a change
1039
+ * that trips both still collapses to one reload. Debounced (a temp+rename burst → one reload),
1040
+ * with a re-entrancy guard so a mid-flight expose reload can't overlap itself. A watch failure (an
1041
+ * fs that supports neither) is logged and ignored — the server still runs, just without
1042
+ * auto-reload. NEVER throws.
974
1043
  *
975
1044
  * @param {object} build the live build object (mutated by the reloaders)
976
1045
  * @param {(obj:object)=>void} send the stdio writer
977
1046
  * @returns {void}
978
1047
  */
979
- function startConfigWatchers(build, send) {
1048
+ function startConfigWatchers(buildArg, send) {
1049
+ // buildArg may be the build OBJECT (stdio: one stable object, mutated in place) OR a GETTER
1050
+ // () => build. The HTTP host REBUILDS its build (reload()/stop() reassign it), so it passes a
1051
+ // getter — the reloaders then always target the LIVE build and a rebuild can never desync the
1052
+ // watchers. Backward-compatible: a plain object is wrapped in a getter, so stdio is unchanged.
1053
+ const getBuild = typeof buildArg === 'function' ? buildArg : () => buildArg;
980
1054
  const DEBOUNCE_MS = 150;
981
1055
  const mcpDir = path.dirname(EXPOSE_PATH);
982
1056
  const hooksDir = path.dirname(MANIFEST_PATH);
1057
+ const openWatchers = []; // fs.watch handles, closed by the returned stop()
1058
+ const polledFiles = []; // fs.watchFile paths, unwatched by the returned stop()
983
1059
 
984
1060
  let exposeTimer = null;
985
1061
  let reloadingExpose = false;
@@ -989,7 +1065,12 @@ function startConfigWatchers(build, send) {
989
1065
  exposeTimer = null;
990
1066
  if (reloadingExpose) { scheduleExpose(); return; } // a reload is mid-flight; re-arm
991
1067
  reloadingExpose = true;
992
- try { await reloadExpose(build, send); } finally { reloadingExpose = false; }
1068
+ // reloadExpose is contracted never to throw, but a debounced setTimeout callback has no
1069
+ // caller to catch an escaped rejection — so guard here too (an unhandled rejection would
1070
+ // crash the host on default Node). Belt and braces.
1071
+ try { await reloadExpose(getBuild(), send); }
1072
+ catch (e) { logErr('scheduleExpose: reload rejected (should not):', (e && e.message) || String(e)); }
1073
+ finally { reloadingExpose = false; }
993
1074
  }, DEBOUNCE_MS);
994
1075
  if (exposeTimer && typeof exposeTimer.unref === 'function') exposeTimer.unref(); // don't pin the loop
995
1076
  }
@@ -997,10 +1078,27 @@ function startConfigWatchers(build, send) {
997
1078
  let hooksTimer = null;
998
1079
  function scheduleHooks() {
999
1080
  if (hooksTimer) clearTimeout(hooksTimer);
1000
- hooksTimer = setTimeout(() => { hooksTimer = null; reloadHooks(build); }, DEBOUNCE_MS);
1081
+ hooksTimer = setTimeout(() => { hooksTimer = null; reloadHooks(getBuild()); }, DEBOUNCE_MS);
1001
1082
  if (hooksTimer && typeof hooksTimer.unref === 'function') hooksTimer.unref();
1002
1083
  }
1003
1084
 
1085
+ let registerTimer = null;
1086
+ function scheduleRegister() {
1087
+ if (registerTimer) clearTimeout(registerTimer);
1088
+ registerTimer = setTimeout(() => { registerTimer = null; reloadRegister(getBuild(), send); }, DEBOUNCE_MS);
1089
+ if (registerTimer && typeof registerTimer.unref === 'function') registerTimer.unref();
1090
+ }
1091
+
1092
+ // tools.state.json needs NO reload (it is read fresh per tools/list & tools/call) — but a
1093
+ // hot/hidden/enabled toggle CHANGES the top-level surface, so a connected client must be told
1094
+ // to re-fetch. Notification only.
1095
+ let stateTimer = null;
1096
+ function scheduleStateNotify() {
1097
+ if (stateTimer) clearTimeout(stateTimer);
1098
+ stateTimer = setTimeout(() => { stateTimer = null; emitToolsListChanged(send); }, DEBOUNCE_MS);
1099
+ if (stateTimer && typeof stateTimer.unref === 'function') stateTimer.unref();
1100
+ }
1101
+
1004
1102
  try {
1005
1103
  const w = fs.watch(mcpDir, (_event, filename) => {
1006
1104
  // null filename (some platforms don't report it) → reload to be safe; else only expose.json.
@@ -1009,6 +1107,7 @@ function startConfigWatchers(build, send) {
1009
1107
  // unref so an FSWatcher never keeps the process alive after the client closes stdin — the
1010
1108
  // gateway must still exit naturally when its transport closes (it did before this feature).
1011
1109
  if (w && typeof w.unref === 'function') w.unref();
1110
+ openWatchers.push(w);
1012
1111
  logErr('startConfigWatchers: watching', mcpDir, 'for expose.json changes');
1013
1112
  } catch (err) {
1014
1113
  logErr('startConfigWatchers: cannot watch', mcpDir + ':', (err && err.message) || String(err));
@@ -1020,10 +1119,76 @@ function startConfigWatchers(build, send) {
1020
1119
  if (!filename || base === 'hooks.manifest.json' || base === 'hooks.state.json') scheduleHooks();
1021
1120
  });
1022
1121
  if (w && typeof w.unref === 'function') w.unref();
1122
+ openWatchers.push(w);
1023
1123
  logErr('startConfigWatchers: watching', hooksDir, 'for hook changes');
1024
1124
  } catch (err) {
1025
1125
  logErr('startConfigWatchers: cannot watch', hooksDir + ':', (err && err.message) || String(err));
1026
1126
  }
1127
+
1128
+ // The register + state overlay live in tools/. Register edits (tf_tool_add from its child
1129
+ // process, the UI, a hand edit) were the ONE config surface with no watcher — a running gateway
1130
+ // served its startup snapshot until restart, which broke the "add a tool live" story the moment
1131
+ // the server outlived the edit. State toggles only need the list_changed nudge (read-fresh).
1132
+ const toolsDir = path.dirname(REGISTER_PATH);
1133
+ try {
1134
+ const w = fs.watch(toolsDir, (_event, filename) => {
1135
+ const base = filename ? path.basename(String(filename)) : '';
1136
+ if (!filename || base === 'tools.register.json') scheduleRegister();
1137
+ if (!filename || base === 'tools.state.json') scheduleStateNotify();
1138
+ });
1139
+ if (w && typeof w.unref === 'function') w.unref();
1140
+ openWatchers.push(w);
1141
+ logErr('startConfigWatchers: watching', toolsDir, 'for register/state changes');
1142
+ } catch (err) {
1143
+ logErr('startConfigWatchers: cannot watch', toolsDir + ':', (err && err.message) || String(err));
1144
+ }
1145
+
1146
+ // ── Reliable fallback: fs.watchFile POLLS each config file's stat, so it catches every
1147
+ // create/modify — including the atomic temp+rename writes that fs.watch misses on Windows.
1148
+ // A 1s interval is ample for config (no sub-second reload need); the schedulers debounce, so a
1149
+ // change caught by BOTH the directory watch and the file poll still yields a single reload.
1150
+ // The state overlays (hooks.state.json / tools.state.json) may not exist yet — watchFile fires
1151
+ // when they first appear (a tf_hook_set / tf_tool_set toggle CREATES them), which is the case
1152
+ // the directory watch was dropping.
1153
+ const pollWatch = (file, schedule) => {
1154
+ try {
1155
+ // Retain the listener ref so stop() can unwatchFile(file, listener) — a bare
1156
+ // unwatchFile(file) would remove ALL listeners on that path, clobbering a second host/watcher
1157
+ // set in the same process (parallel tests, an embedder running two hosts).
1158
+ const listener = (curr, prev) => {
1159
+ if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) schedule();
1160
+ };
1161
+ const sw = fs.watchFile(file, { interval: 1000 }, listener);
1162
+ if (sw && typeof sw.unref === 'function') sw.unref(); // never pin the event loop
1163
+ polledFiles.push({ file, listener });
1164
+ } catch (err) {
1165
+ logErr('startConfigWatchers: cannot poll-watch', file + ':', (err && err.message) || String(err));
1166
+ }
1167
+ };
1168
+ pollWatch(EXPOSE_PATH, scheduleExpose);
1169
+ pollWatch(MANIFEST_PATH, scheduleHooks);
1170
+ pollWatch(path.join(hooksDir, 'hooks.state.json'), scheduleHooks);
1171
+ pollWatch(REGISTER_PATH, scheduleRegister);
1172
+ pollWatch(path.join(toolsDir, 'tools.state.json'), scheduleStateNotify);
1173
+ logErr('startConfigWatchers: fs.watchFile polling fallback armed (Windows-safe)');
1174
+
1175
+ // Teardown handle. The stdio path never calls it (the process dies when stdin closes and the
1176
+ // watchers are all unref'd); the HTTP host calls it in stop() so a started→stopped host leaves
1177
+ // no dangling directory watchers or file pollers.
1178
+ return function stopConfigWatchers() {
1179
+ // Clear any PENDING debounced reload so a change caught just before teardown can't fire a
1180
+ // reload against a torn-down build after the caller has moved on. (It would be a caught no-op,
1181
+ // but a truly-stopped watcher must not schedule work.)
1182
+ for (const t of [exposeTimer, hooksTimer, registerTimer, stateTimer]) {
1183
+ if (t) { try { clearTimeout(t); } catch (_e) { /* ignore */ } }
1184
+ }
1185
+ for (const w of openWatchers) {
1186
+ try { if (w && typeof w.close === 'function') w.close(); } catch (_e) { /* ignore */ }
1187
+ }
1188
+ for (const p of polledFiles) {
1189
+ try { fs.unwatchFile(p.file, p.listener); } catch (_e) { /* ignore */ }
1190
+ }
1191
+ };
1027
1192
  }
1028
1193
 
1029
1194
  // ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -33,9 +33,11 @@
33
33
  const fs = require('node:fs');
34
34
  const path = require('node:path');
35
35
 
36
- // ROOT resolution mirrors src/mcp/server.js: this file lives at
37
- // <root>/src/packages/packager.js, so two levels up is the project root.
38
- const ROOT = path.resolve(__dirname, '..', '..'); // <…> project root
36
+ // ROOT = the CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root — see
37
+ // src/core/config-home.js). Packages are config-tree citizens: they export FROM and import INTO
38
+ // the home's register/manifest/expose stores, and the packages/ tree itself lives in the home.
39
+ const { resolveConfigHome } = require('../core/config-home');
40
+ const ROOT = resolveConfigHome(); // <…> config home
39
41
 
40
42
  const PACKAGE_MANIFEST = 'package.json';
41
43
 
@@ -130,6 +130,15 @@ function validateEntry(entry, { requireId = true } = {}) {
130
130
  if (inv.type === 'shell' && (typeof inv.command !== 'string' || inv.command.length === 0)) {
131
131
  throw new Error(`Registry: shell invoke needs a non-empty "command" (id=${entry.id})`);
132
132
  }
133
+ // Optional MCP input schema. When present it must be a plain object — a hot-promoted tool
134
+ // advertises it VERBATIM to MCP clients (tools/list), so a typo'd shape must fail loudly at
135
+ // authoring time rather than silently degrade to the free-form fallback at surface time.
136
+ if (
137
+ entry.inputSchema !== undefined &&
138
+ (typeof entry.inputSchema !== 'object' || entry.inputSchema === null || Array.isArray(entry.inputSchema))
139
+ ) {
140
+ throw new Error(`Registry: entry.inputSchema must be an object when present (id=${entry.id})`);
141
+ }
133
142
  return entry;
134
143
  }
135
144
 
@@ -284,6 +293,40 @@ class Registry {
284
293
  return true;
285
294
  }
286
295
 
296
+ /**
297
+ * Re-read the register FILE and swap the in-memory index — the hot-reload seam. A running
298
+ * gateway holds ONE Registry instance (captured by the protocol adapter at build time), while
299
+ * tf_tool_add / the UI / a hand edit mutate the FILE from another process — without this, those
300
+ * edits are invisible until restart. LAST-GOOD semantics: the replacement index is built and
301
+ * fully validated FIRST; any read/parse/validation failure (including a mid-write partial file)
302
+ * leaves the current index untouched and returns false. NEVER throws.
303
+ * @returns {boolean} true when the on-disk register replaced the in-memory index
304
+ */
305
+ reload() {
306
+ let data;
307
+ try {
308
+ data = JSON.parse(fs.readFileSync(this._filePath, 'utf8'));
309
+ } catch (_e) {
310
+ return false; // unreadable / mid-write / bad JSON → keep last-good
311
+ }
312
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return false;
313
+ const tools = Array.isArray(data.tools) ? data.tools : [];
314
+ const next = new Map();
315
+ try {
316
+ for (const t of tools) {
317
+ validateEntry(t);
318
+ if (next.has(t.id)) throw new Error(`duplicate tool id "${t.id}"`);
319
+ next.set(t.id, cloneEntry(t));
320
+ }
321
+ } catch (_e) {
322
+ return false; // an invalid entry → keep last-good
323
+ }
324
+ if (typeof data.version === 'number') this._version = data.version;
325
+ if (typeof data.description === 'string') this._description = data.description;
326
+ this._byId = next;
327
+ return true;
328
+ }
329
+
287
330
  /**
288
331
  * Write a host-local script's text under this scriptsRoot, atomically.
289
332
  *
@@ -354,8 +397,10 @@ class Registry {
354
397
 
355
398
  // ---- reference mode: nothing runs here. ----------------------------------
356
399
  // The connected AI performs the action in its own environment. We hand back the
357
- // tool's instructions (no `run`) so the run-path can short-circuit BEFORE the gate
358
- // — a reference tool never spawns and never needs gating (nothing executes).
400
+ // tool's instructions (no `run`) so the run-path takes the no-spawn branch — a
401
+ // reference tool never spawns server-side. The instruction HANDOFF is still gated
402
+ // in protocol.runTool (a PreToolUse deny withholds the instructions); it's execution,
403
+ // not the gate, that's absent here.
359
404
  if (mode === 'reference') {
360
405
  return {
361
406
  type: 'reference',
package/src/ui/server.js CHANGED
@@ -19,6 +19,9 @@
19
19
  * DIRECTLY (no MCP round-trip). Every write is atomic (temp + rename) inside the store.
20
20
  *
21
21
  * SAFETY CONTRACT (mirrors src/mcp/http-transport.js):
22
+ * - start() HARD-REFUSES a non-loopback bind host. The UI has no auth path at all, and it can
23
+ * spawn processes (mcp add/discover) and write scripts (tools/hooks) — off-loopback it would be
24
+ * an unauthenticated remote console. Loopback-only by design; no flag overrides it.
22
25
  * - Binds 127.0.0.1 by default; a non-loopback Host header is rejected (DNS-rebind guard).
23
26
  * - The request listener NEVER throws — a bad request becomes a clean JSON error.
24
27
  * - start() rejects cleanly on EADDRINUSE (caller decides); stop() is idempotent.
@@ -91,6 +94,17 @@ function isLoopbackHost(hostHeader) {
91
94
  return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1';
92
95
  }
93
96
 
97
+ /**
98
+ * Is the BIND address loopback? STRICTER than the Host-header check above: a missing Host header
99
+ * is legitimately loopback (the bind address is the boundary), but a missing/empty BIND address is
100
+ * not a loopback claim — it must name a loopback host explicitly to pass.
101
+ * @param {string|undefined} bindHost
102
+ * @returns {boolean}
103
+ */
104
+ function isLoopbackBindHost(bindHost) {
105
+ return typeof bindHost === 'string' && bindHost.trim() !== '' && isLoopbackHost(bindHost);
106
+ }
107
+
94
108
  /** Regex-escape a literal so a matcher built from it FULL-matches the string verbatim. */
95
109
  function escapeRegex(str) {
96
110
  return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -183,7 +197,8 @@ function logConfigChange(fields) {
183
197
  * @param {object} [opts]
184
198
  * @param {string} [opts.host] bind address (default 127.0.0.1 — loopback only).
185
199
  * @param {number} [opts.port] bind port (default 9777; pass 0 for an OS-assigned port).
186
- * @param {string} [opts.root] repo root holding tools/ mcp/ hooks/ (default path.resolve(__dirname,'..','..')).
200
+ * @param {string} [opts.root] config root holding tools/ mcp/ hooks/ (default: the resolved
201
+ * config home — TOOLFUNNEL_HOME / --config-dir / the package root).
187
202
  * @returns {{ start: () => Promise<{url:string, port:number}>, stop: () => Promise<void>, get url():(string|null) }}
188
203
  */
189
204
  function createUiServer(opts = {}) {
@@ -191,8 +206,11 @@ function createUiServer(opts = {}) {
191
206
  const host = typeof o.host === 'string' && o.host.length > 0 ? o.host : DEFAULT_HOST;
192
207
  // A port of 0 is VALID (OS-assigned) so distinguish "not provided" from 0.
193
208
  const requestedPort = Number.isInteger(o.port) && o.port >= 0 ? o.port : DEFAULT_PORT;
194
- // The repo root: tools.register.json etc. live under <root>/tools, <root>/mcp, <root>/hooks.
195
- const root = typeof o.root === 'string' && o.root.length > 0 ? o.root : path.resolve(__dirname, '..', '..');
209
+ // The config root: tools.register.json etc. live under <root>/tools, <root>/mcp, <root>/hooks.
210
+ // Default = the resolved config home (see src/core/config-home.js), so a --config-dir /
211
+ // TOOLFUNNEL_HOME gateway and its UI edit the SAME stores.
212
+ const { resolveConfigHome } = require('../core/config-home');
213
+ const root = typeof o.root === 'string' && o.root.length > 0 ? o.root : resolveConfigHome();
196
214
 
197
215
  // ── Path anchors (everything resolved from the repo root) ────────────────────────────────────
198
216
  const REGISTER_PATH = path.join(root, 'tools', 'tools.register.json');
@@ -990,6 +1008,23 @@ function createUiServer(opts = {}) {
990
1008
  const pathName = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
991
1009
  const method = (req.method || 'GET').toUpperCase();
992
1010
 
1011
+ // CSRF guard. The bind address stops the NETWORK, not the browser: any web page the
1012
+ // operator visits can fire a no-cors POST at 127.0.0.1:<port>, and the mutating routes
1013
+ // below write tool scripts and register process-spawning upstreams. Browsers ALWAYS send
1014
+ // an Origin header on cross-origin POSTs, so: a PRESENT Origin must itself be loopback
1015
+ // (the UI's own pages), while an ABSENT Origin (curl, CLI clients, non-browser tools) is
1016
+ // allowed — for those, the bind address remains the boundary. GETs stay open (read-only).
1017
+ if (method !== 'GET') {
1018
+ const origin = req.headers && req.headers.origin;
1019
+ if (origin) {
1020
+ let originHost = null;
1021
+ try { originHost = new URL(origin).host; } catch (_e) { /* malformed → reject below */ }
1022
+ if (!originHost || !isLoopbackHost(originHost)) {
1023
+ return sendJson(res, 403, { ok: false, error: 'forbidden: cross-origin request (CSRF guard)' });
1024
+ }
1025
+ }
1026
+ }
1027
+
993
1028
  // ── Static assets ─────────────────────────────────────────────────────────────────────────
994
1029
  if (method === 'GET' && (pathName === '/' || pathName === '/index.html')) {
995
1030
  return sendStatic(res, 'index.html');
@@ -1079,6 +1114,16 @@ function createUiServer(opts = {}) {
1079
1114
  */
1080
1115
  function start() {
1081
1116
  if (started) return Promise.resolve({ url: currentUrl(), port: boundPort });
1117
+ // The HTTP transport refuses a non-loopback bind unless OAuth is enabled; the UI has NO auth
1118
+ // path, so its refusal is unconditional. This endpoint spawns processes and writes scripts —
1119
+ // "--ui --host 0.0.0.0" would hand an unauthenticated remote console to the LAN.
1120
+ if (!isLoopbackBindHost(host)) {
1121
+ return Promise.reject(new Error(
1122
+ `refusing to bind the config UI to non-loopback host "${host}" — the UI is unauthenticated ` +
1123
+ 'and can spawn processes / write tool scripts. It is loopback-only by design; to configure ' +
1124
+ 'the gateway from another machine, use an SSH tunnel to 127.0.0.1 instead.'
1125
+ ));
1126
+ }
1082
1127
  httpServer = http.createServer(onRequest);
1083
1128
  return new Promise((resolve, reject) => {
1084
1129
  const onListenError = (err) => {
@@ -1140,5 +1185,6 @@ module.exports = {
1140
1185
  createUiServer,
1141
1186
  // Exported for unit tests / reuse — the small pure seams.
1142
1187
  isLoopbackHost,
1188
+ isLoopbackBindHost,
1143
1189
  escapeRegex,
1144
1190
  };
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * tf-env.js — shared HOME/ENGINE resolution for the tf-* management scripts.
5
+ *
6
+ * A management script runs in one of two layouts:
7
+ * 1. The git-clone original: it lives at <pkg>/tools/scripts/, and TWO dirs up is a root that
8
+ * holds BOTH the config (tools/ mcp/ hooks/) and the engine code (src/).
9
+ * 2. A copy SEEDED into an external CONFIG HOME (TOOLFUNNEL_HOME / --config-dir — see
10
+ * src/core/config-home.js): the config sits beside it, but the engine code does NOT.
11
+ * The gateway that spawned it wrote the process-tree contract into the env:
12
+ * TOOLFUNNEL_HOME (the resolved absolute home) + TOOLFUNNEL_PKG (the package root).
13
+ *
14
+ * Resolution rules:
15
+ * - ENGINE: prefer the LOCAL __dirname-derived root whenever it actually contains the engine
16
+ * (layout 1) so a stale inherited TOOLFUNNEL_PKG can never shadow a real install; else the
17
+ * env; else the local root anyway (the require failure is then the clearest error).
18
+ * - HOME: TOOLFUNNEL_HOME when set (the gateway always sets it), else the local root.
19
+ *
20
+ * Same-dir require ('./tf-env') keeps this working in BOTH layouts — the seeder copies the whole
21
+ * scripts directory, so this file always travels with its callers. Node built-ins only.
22
+ */
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+
27
+ const LOCAL_ROOT = path.resolve(__dirname, '..', '..');
28
+ const HAS_LOCAL_ENGINE = fs.existsSync(path.join(LOCAL_ROOT, 'src', 'tools', 'registry.js'));
29
+
30
+ /** The package root holding the engine code (src/…). */
31
+ const PKG = HAS_LOCAL_ENGINE ? LOCAL_ROOT : (process.env.TOOLFUNNEL_PKG || LOCAL_ROOT);
32
+
33
+ /** The config home holding tools/ mcp/ hooks/ (the mutable state this script edits). */
34
+ const HOME = typeof process.env.TOOLFUNNEL_HOME === 'string' && process.env.TOOLFUNNEL_HOME.trim().length > 0
35
+ ? path.resolve(process.env.TOOLFUNNEL_HOME.trim())
36
+ : LOCAL_ROOT;
37
+
38
+ /** Require an engine module by its src-relative path, e.g. srcRequire('tools/registry'). */
39
+ function srcRequire(rel) {
40
+ return require(path.join(PKG, 'src', rel));
41
+ }
42
+
43
+ module.exports = { PKG, HOME, srcRequire };
@@ -37,12 +37,11 @@
37
37
  */
38
38
 
39
39
  const path = require('node:path');
40
- const { loadManifest } = require('../../src/core/hook-loader');
40
+ // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
41
+ const { HOME, srcRequire } = require('./tf-env');
42
+ const { loadManifest } = srcRequire('core/hook-loader');
41
43
 
42
- /**
43
- * ToolFunnel root, two dirs up from this script (tools/scripts -> tools -> root).
44
- */
45
- const ROOT = path.resolve(__dirname, '..', '..');
44
+ const ROOT = HOME;
46
45
 
47
46
  /** The hook manifest, relative to ROOT. */
48
47
  const MANIFEST = path.join(ROOT, 'hooks', 'hooks.manifest.json');
@@ -33,12 +33,11 @@
33
33
  */
34
34
 
35
35
  const path = require('node:path');
36
- const { loadManifest } = require('../../src/core/hook-loader');
36
+ // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
37
+ const { HOME, srcRequire } = require('./tf-env');
38
+ const { loadManifest } = srcRequire('core/hook-loader');
37
39
 
38
- /**
39
- * ToolFunnel root, two dirs up from this script (tools/scripts -> tools -> root).
40
- */
41
- const ROOT = path.resolve(__dirname, '..', '..');
40
+ const ROOT = HOME;
42
41
 
43
42
  /** The hook manifest, relative to ROOT. */
44
43
  const MANIFEST = path.join(ROOT, 'hooks', 'hooks.manifest.json');
@@ -48,19 +48,16 @@
48
48
 
49
49
  const path = require('node:path');
50
50
 
51
- const { loadRegistry } = require('../../src/tools/registry');
52
- const { loadToolState, isToolEnabled, isToolHidden } = require('../../src/tools/tool-state');
53
- const { loadExposeStore } = require('../../src/mcp/expose-store');
54
- const { loadManifest } = require('../../src/core/hook-loader');
51
+ // HOME/engine resolution shared by every tf-* script (see tf-env.js): the config lives beside
52
+ // this script (the git-clone root OR a seeded external config home); the engine code is required
53
+ // from the package that spawned us when it is not local.
54
+ const { HOME, srcRequire } = require('./tf-env');
55
+ const { loadRegistry } = srcRequire('tools/registry');
56
+ const { loadToolState, isToolEnabled, isToolHidden } = srcRequire('tools/tool-state');
57
+ const { loadExposeStore } = srcRequire('mcp/expose-store');
58
+ const { loadManifest } = srcRequire('core/hook-loader');
55
59
 
56
- /**
57
- * The toolfunnel root, resolved from THIS file's location so the tool stays inside
58
- * the sandbox and is portable wherever the tree is copied.
59
- *
60
- * This script lives at: <root>/tools/scripts/tf-list.js
61
- * so the root is two directories up from __dirname: scripts -> tools -> <root>
62
- */
63
- const ROOT = path.resolve(__dirname, '..', '..');
60
+ const ROOT = HOME;
64
61
 
65
62
  // Canonical store paths (see the script contract).
66
63
  const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
@@ -21,7 +21,10 @@
21
21
  * failure: { ok:false, error }
22
22
  */
23
23
 
24
- const logger = require('../../src/core/logger');
24
+ // Shared HOME/engine resolution (see tf-env.js): the logger module itself resolves the config
25
+ // home from the env the gateway set, so requiring it from the right PACKAGE is all we need.
26
+ const { srcRequire } = require('./tf-env');
27
+ const logger = srcRequire('core/logger');
25
28
 
26
29
  /**
27
30
  * Parse TOOLFUNNEL_TOOL_ARGS (a JSON string; {} if absent). Throws on malformed JSON
@@ -41,13 +41,11 @@
41
41
  */
42
42
 
43
43
  const path = require('node:path');
44
- const { loadExposeStore } = require('../../src/mcp/expose-store');
44
+ // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
45
+ const { HOME, srcRequire } = require('./tf-env');
46
+ const { loadExposeStore } = srcRequire('mcp/expose-store');
45
47
 
46
- /**
47
- * ToolFunnel root, resolved from THIS file's location:
48
- * <root>/tools/scripts/tf-mcp-add.js -> root is two dirs up.
49
- */
50
- const ROOT = path.resolve(__dirname, '..', '..');
48
+ const ROOT = HOME;
51
49
 
52
50
  /** The MCP config (upstreams + expose), per the host path contract. */
53
51
  const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
@@ -29,13 +29,11 @@
29
29
  */
30
30
 
31
31
  const path = require('node:path');
32
- const { loadExposeStore } = require('../../src/mcp/expose-store');
32
+ // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
33
+ const { HOME, srcRequire } = require('./tf-env');
34
+ const { loadExposeStore } = srcRequire('mcp/expose-store');
33
35
 
34
- /**
35
- * ToolFunnel root, resolved from THIS file's location:
36
- * <root>/tools/scripts/tf-mcp-set.js -> root is two dirs up.
37
- */
38
- const ROOT = path.resolve(__dirname, '..', '..');
36
+ const ROOT = HOME;
39
37
 
40
38
  /** The MCP config (upstreams + expose), per the host path contract. */
41
39
  const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');