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.
- package/README.md +167 -324
- package/bin/toolfunnel.js +39 -4
- package/docs/KNOWN_BUGS.md +33 -0
- package/docs/MANUAL.pdf +0 -0
- package/docs/packaging.md +115 -0
- 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 +71 -21
- package/src/mcp/mcp-client.js +9 -3
- package/src/mcp/server.js +181 -16
- package/src/packages/packager.js +5 -3
- package/src/tools/registry.js +47 -2
- 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/docs/admin-ui.md +0 -404
- package/docs/configuration.md +0 -402
- package/docs/hooks-and-gating.md +0 -428
- package/docs/logging.md +0 -223
- package/docs/management.md +0 -429
- package/docs/oauth.md +0 -103
- package/docs/setup.md +0 -352
- 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
|
-
|
|
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) {
|
|
@@ -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
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
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
|
*
|
|
@@ -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
|
|
358
|
-
//
|
|
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]
|
|
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');
|