toolfunnel 0.3.0 → 0.4.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.
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) {
@@ -248,6 +266,11 @@ function makeRegistryAdapter(registry, opts) {
248
266
  // - engine, ctx: threaded into gatedRun for the curated-direct path (a curated tool runs
249
267
  // THROUGH the same PreToolUse gate as toolfunnel_run_tool — the safety invariant).
250
268
  function buildProtocol() {
269
+ // Make the config home real before anything reads from it: seeds an external home's register/
270
+ // scripts/expose/hooks from the shipped defaults on first use. A no-op when home === package
271
+ // root; idempotent and never-overwriting afterwards. A failure falls through to loadRegistry's
272
+ // own clear error.
273
+ try { ensureConfigHome(ROOT); } catch (err) { logErr('config-home init failed:', (err && err.message) || String(err)); }
251
274
  const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
252
275
  const loader = loadManifest(MANIFEST_PATH);
253
276
 
@@ -947,6 +970,31 @@ async function reloadExpose(build, send) {
947
970
  logErr('reloadExpose: expose.json reloaded — upstreams reconnected');
948
971
  }
949
972
 
973
+ /**
974
+ * Re-read tools.register.json and swap the ONE live Registry's index IN PLACE (Registry.reload —
975
+ * last-good on any failure). In-place mutation is the point: the Registry instance is CAPTURED by
976
+ * the protocol adapter at build time, so swapping internals means every reader — the lean
977
+ * meta-tools, run_tool resolution, and the top-level hot surface — sees the new entries on its
978
+ * next call with no rewiring. Emits list_changed: a hot-promoted local tool's definition may have
979
+ * appeared, changed, or gone. NEVER throws.
980
+ *
981
+ * @param {{registry?:object}} build
982
+ * @param {(obj:object)=>void} send the stdio writer, for the list_changed notification
983
+ * @returns {void}
984
+ */
985
+ function reloadRegister(build, send) {
986
+ try {
987
+ if (build && build.registry && typeof build.registry.reload === 'function' && build.registry.reload()) {
988
+ emitToolsListChanged(send);
989
+ logErr('reloadRegister: tools.register.json reloaded');
990
+ } else {
991
+ logErr('reloadRegister: reload failed (bad JSON / invalid entry / mid-write?) — keeping the last-good register');
992
+ }
993
+ } catch (err) {
994
+ logErr('reloadRegister: unexpected failure (keeping current register):', (err && err.message) || String(err));
995
+ }
996
+ }
997
+
950
998
  /**
951
999
  * Re-read the hooks manifest + state overlay and swap a fresh HookEngine into `build`. The next
952
1000
  * gated call (toolfunnel_run_tool / curated-direct) uses the new engine, so an added/changed/toggled
@@ -1001,6 +1049,23 @@ function startConfigWatchers(build, send) {
1001
1049
  if (hooksTimer && typeof hooksTimer.unref === 'function') hooksTimer.unref();
1002
1050
  }
1003
1051
 
1052
+ let registerTimer = null;
1053
+ function scheduleRegister() {
1054
+ if (registerTimer) clearTimeout(registerTimer);
1055
+ registerTimer = setTimeout(() => { registerTimer = null; reloadRegister(build, send); }, DEBOUNCE_MS);
1056
+ if (registerTimer && typeof registerTimer.unref === 'function') registerTimer.unref();
1057
+ }
1058
+
1059
+ // tools.state.json needs NO reload (it is read fresh per tools/list & tools/call) — but a
1060
+ // hot/hidden/enabled toggle CHANGES the top-level surface, so a connected client must be told
1061
+ // to re-fetch. Notification only.
1062
+ let stateTimer = null;
1063
+ function scheduleStateNotify() {
1064
+ if (stateTimer) clearTimeout(stateTimer);
1065
+ stateTimer = setTimeout(() => { stateTimer = null; emitToolsListChanged(send); }, DEBOUNCE_MS);
1066
+ if (stateTimer && typeof stateTimer.unref === 'function') stateTimer.unref();
1067
+ }
1068
+
1004
1069
  try {
1005
1070
  const w = fs.watch(mcpDir, (_event, filename) => {
1006
1071
  // null filename (some platforms don't report it) → reload to be safe; else only expose.json.
@@ -1024,6 +1089,23 @@ function startConfigWatchers(build, send) {
1024
1089
  } catch (err) {
1025
1090
  logErr('startConfigWatchers: cannot watch', hooksDir + ':', (err && err.message) || String(err));
1026
1091
  }
1092
+
1093
+ // The register + state overlay live in tools/. Register edits (tf_tool_add from its child
1094
+ // process, the UI, a hand edit) were the ONE config surface with no watcher — a running gateway
1095
+ // served its startup snapshot until restart, which broke the "add a tool live" story the moment
1096
+ // the server outlived the edit. State toggles only need the list_changed nudge (read-fresh).
1097
+ const toolsDir = path.dirname(REGISTER_PATH);
1098
+ try {
1099
+ const w = fs.watch(toolsDir, (_event, filename) => {
1100
+ const base = filename ? path.basename(String(filename)) : '';
1101
+ if (!filename || base === 'tools.register.json') scheduleRegister();
1102
+ if (!filename || base === 'tools.state.json') scheduleStateNotify();
1103
+ });
1104
+ if (w && typeof w.unref === 'function') w.unref();
1105
+ logErr('startConfigWatchers: watching', toolsDir, 'for register/state changes');
1106
+ } catch (err) {
1107
+ logErr('startConfigWatchers: cannot watch', toolsDir + ':', (err && err.message) || String(err));
1108
+ }
1027
1109
  }
1028
1110
 
1029
1111
  // ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -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
  *
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');