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/README.md +24 -6
- package/bin/toolfunnel.js +39 -4
- package/docs/KNOWN_BUGS.md +225 -0
- package/docs/packaging.md +115 -0
- package/docs/releasing.md +31 -0
- package/docs/setup.md +1 -1
- package/package.json +3 -1
- package/src/auth/config.js +4 -2
- package/src/auth/resource-server.js +28 -3
- package/src/core/config-home.js +134 -0
- package/src/core/logger.js +4 -2
- package/src/core/requires.js +138 -0
- package/src/core/server-config.js +77 -0
- package/src/extend/create-tool.md +24 -4
- package/src/extend/package.md +33 -0
- package/src/mcp/aggregator.js +12 -7
- package/src/mcp/http-transport.js +40 -21
- package/src/mcp/mcp-client.js +9 -3
- package/src/mcp/server.js +84 -2
- package/src/packages/packager.js +5 -3
- package/src/tools/registry.js +43 -0
- package/src/ui/server.js +49 -3
- package/tools/scripts/tf-env.js +43 -0
- package/tools/scripts/tf-hook-add.js +4 -5
- package/tools/scripts/tf-hook-set.js +4 -5
- package/tools/scripts/tf-list.js +9 -12
- package/tools/scripts/tf-log.js +4 -1
- package/tools/scripts/tf-mcp-add.js +4 -6
- package/tools/scripts/tf-mcp-set.js +4 -6
- package/tools/scripts/tf-pack.js +237 -0
- package/tools/scripts/tf-tool-add.js +16 -16
- package/tools/scripts/tf-tool-set.js +5 -11
- package/tools/tools.register.json +46 -1
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
|
-
|
|
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
|
-
|
|
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
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
package/src/packages/packager.js
CHANGED
|
@@ -33,9 +33,11 @@
|
|
|
33
33
|
const fs = require('node:fs');
|
|
34
34
|
const path = require('node:path');
|
|
35
35
|
|
|
36
|
-
// ROOT
|
|
37
|
-
//
|
|
38
|
-
|
|
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
|
|
package/src/tools/registry.js
CHANGED
|
@@ -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]
|
|
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
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
package/tools/scripts/tf-list.js
CHANGED
|
@@ -48,19 +48,16 @@
|
|
|
48
48
|
|
|
49
49
|
const path = require('node:path');
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const {
|
|
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');
|
package/tools/scripts/tf-log.js
CHANGED
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
* failure: { ok:false, error }
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|