toolfunnel 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +228 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +451 -451
  4. package/docs/KNOWN_BUGS.md +80 -80
  5. package/docs/design.md +84 -84
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +39 -39
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -147
  12. package/mcp/servers/mock-upstream/server.js +380 -380
  13. package/package.json +67 -67
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +353 -353
  17. package/src/core/child-reaper.js +61 -61
  18. package/src/core/config-home.js +134 -134
  19. package/src/core/events.js +105 -105
  20. package/src/core/hook-engine.js +329 -329
  21. package/src/core/hook-loader.js +824 -824
  22. package/src/core/hook-runner.js +566 -566
  23. package/src/core/logger.js +297 -297
  24. package/src/core/matcher.js +100 -100
  25. package/src/core/metrics.js +75 -75
  26. package/src/core/rename-migration.js +163 -163
  27. package/src/core/requires.js +138 -138
  28. package/src/core/server-config.js +98 -98
  29. package/src/extend/add-hook.md +152 -152
  30. package/src/extend/add-mcp.md +119 -119
  31. package/src/extend/configure.md +88 -88
  32. package/src/extend/create-tool.md +168 -168
  33. package/src/extend/howto.js +102 -102
  34. package/src/extend/package.md +190 -190
  35. package/src/extend/wrap.md +99 -99
  36. package/src/mcp/aggregator.js +1719 -1674
  37. package/src/mcp/expose-store.js +577 -577
  38. package/src/mcp/gated-run.js +210 -210
  39. package/src/mcp/http-transport.js +1563 -1563
  40. package/src/mcp/mcp-client.js +1070 -1070
  41. package/src/mcp/modern.js +406 -406
  42. package/src/mcp/protocol.js +468 -468
  43. package/src/mcp/server.js +3112 -3112
  44. package/src/tools/registry.js +661 -661
  45. package/src/tools/tool-state.js +170 -170
  46. package/src/ui/public/app.js +1187 -1187
  47. package/src/ui/public/index.html +286 -286
  48. package/src/ui/public/styles.css +535 -535
  49. package/src/ui/server.js +1616 -1616
  50. package/tools/scripts/base64.js +139 -139
  51. package/tools/scripts/danger.js +61 -61
  52. package/tools/scripts/echo.js +39 -39
  53. package/tools/scripts/hash.js +101 -101
  54. package/tools/scripts/json.js +109 -109
  55. package/tools/scripts/text-stats.js +131 -131
  56. package/tools/scripts/tf-env.js +43 -43
  57. package/tools/scripts/tf-hook-add.js +151 -151
  58. package/tools/scripts/tf-hook-set.js +120 -120
  59. package/tools/scripts/tf-list.js +192 -192
  60. package/tools/scripts/tf-log.js +86 -86
  61. package/tools/scripts/tf-mcp-add.js +117 -117
  62. package/tools/scripts/tf-mcp-set.js +121 -121
  63. package/tools/scripts/tf-pack.js +237 -237
  64. package/tools/scripts/tf-tool-add.js +134 -134
  65. package/tools/scripts/tf-tool-set.js +142 -142
  66. package/tools/scripts/tf-wrap.js +103 -103
  67. package/tools/scripts/uuid.js +102 -102
@@ -1,163 +1,163 @@
1
- 'use strict';
2
-
3
- /**
4
- * rename-migration.js - keep gates and per-tool state attached across a SURFACED-NAME change
5
- * (0.7.0; the rename-safety.test.js invariants are the acceptance).
6
- *
7
- * A tool's SURFACED name is the string the PreToolUse gate matches and the tools.state.json
8
- * key is written against. 0.6.0 let that name change (an expose `as` edit, an enabled flip, a
9
- * local display-name edit) while the gate matcher and the state key silently kept the OLD
10
- * string - a deny gate stopped firing and a disabled tool resurrected. The policy here,
11
- * MATCHER-MIGRATE-ON-RENAME:
12
- *
13
- * - A WILDCARD matcher ('', '*', unset) fires for every tool name - it already covers the
14
- * new name; untouched.
15
- * - An EXACT-LITERAL matcher - the `escapeRegex(oldName)` shape every ToolFunnel surface
16
- * (tf_* tools, the UI) writes - is REWRITTEN to `escapeRegex(newName)`.
17
- * - Any OTHER matcher that would fire for the old name (same anchored-regex semantics as
18
- * core/matcher.js, the gate's own matching) is one WE DID NOT AUTHOR and cannot safely
19
- * rewrite: the rename is REFUSED with the hook named. Update the hook first, then rename.
20
- * - tools.state.json: the old key's entry (enabled/hidden/hot) MOVES to the new key. A
21
- * collision (both keys present with different content) refuses - never clobber curation.
22
- *
23
- * Validation runs COMPLETELY before the first write, and the caller invokes this BEFORE
24
- * persisting its own rename - a refusal leaves every file untouched.
25
- *
26
- * CommonJS only. Node built-ins + core/matcher only (core/ stays headless).
27
- */
28
-
29
- const fs = require('node:fs');
30
- const path = require('node:path');
31
- const { matches, matcherError } = require('./matcher');
32
-
33
- /** Regex-escape a literal so a matcher built from it FULL-matches the string verbatim.
34
- * (Same shape as ui/server.js escapeRegex - the shape ToolFunnel-authored matchers use.) */
35
- function escapeRegex(str) {
36
- return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
- }
38
-
39
- /** Copied exactly from registry.js::atomicWriteJson - crash leaves old file or new, never half. */
40
- function atomicWriteJson(targetPath, obj) {
41
- const dir = path.dirname(targetPath);
42
- const base = path.basename(targetPath);
43
- const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
44
- const data = JSON.stringify(obj, null, 2) + '\n';
45
- const fd = fs.openSync(tmp, 'w');
46
- try {
47
- fs.writeSync(fd, data, 0, 'utf8');
48
- fs.fsyncSync(fd);
49
- } finally {
50
- fs.closeSync(fd);
51
- }
52
- fs.renameSync(tmp, targetPath);
53
- }
54
-
55
- function readJsonIfPresent(p) {
56
- try {
57
- return JSON.parse(fs.readFileSync(p, 'utf8'));
58
- } catch (_e) {
59
- return null; // absent or unreadable -> nothing to migrate there
60
- }
61
- }
62
-
63
- /**
64
- * Migrate every gate matcher and state key from oldName to newName, or throw a refusal.
65
- * @param {object} o
66
- * @param {string} o.hooksPath hooks.manifest.json path (may be absent on disk)
67
- * @param {string} o.statePath tools.state.json path (may be absent on disk)
68
- * @param {string} o.oldName the surfaced name being retired
69
- * @param {string} o.newName the surfaced name taking over
70
- */
71
- function migrateSurfacedName(o) {
72
- const { hooksPath, statePath, oldName, newName } = o || {};
73
- if (typeof oldName !== 'string' || typeof newName !== 'string' || oldName === newName) return;
74
-
75
- // ── Phase A: VALIDATE everything, write nothing ─────────────────────────────────────────────
76
- const manifest = hooksPath ? readJsonIfPresent(hooksPath) : null;
77
- const hooks = manifest && Array.isArray(manifest.hooks) ? manifest.hooks : [];
78
- const literal = escapeRegex(oldName);
79
- const rewrites = [];
80
- for (const h of hooks) {
81
- if (!h || typeof h !== 'object') continue;
82
- const m = h.matcher;
83
- if (m === undefined || m === null || m === '' || m === '*') continue; // wildcard covers both names
84
- if (m === literal || m === oldName) {
85
- // The exact-literal shape ToolFunnel authors (escaped, or the bare name when it contains
86
- // no regex specials - the two are identical then, but hand-written manifests may carry
87
- // the bare form for a name that DOES contain specials, e.g. "my.tool").
88
- rewrites.push(h);
89
- continue;
90
- }
91
- // A matcher that does not COMPILE cannot be evaluated against oldName at all - matches()
92
- // would answer false, silently carrying the rename past a hook this code cannot read.
93
- // Same policy as a matching non-literal: refuse, name the hook, touch nothing.
94
- // (addEntry refuses these at authoring, so only a hand-edited manifest reaches here.)
95
- const compileProblem = matcherError(m);
96
- if (compileProblem) {
97
- throw new Error(
98
- `rename refused: hook "${h.id || '(unnamed)'}" matcher ${JSON.stringify(m)} does not ` +
99
- `compile (${compileProblem}) - it cannot be checked against "${oldName}", so the rename ` +
100
- 'cannot proceed safely. Fix the hook first, then rename'
101
- );
102
- }
103
- if (matches(m, oldName)) {
104
- throw new Error(
105
- `rename refused: hook "${h.id || '(unnamed)'}" matcher "${m}" matches "${oldName}" but is ` +
106
- 'not an exact-literal matcher ToolFunnel can rewrite - update the hook to cover ' +
107
- `"${newName}" first, then rename`
108
- );
109
- }
110
- }
111
-
112
- const state = statePath ? readJsonIfPresent(statePath) : null;
113
- const hasOldState = !!(state && Object.prototype.hasOwnProperty.call(state, oldName));
114
- if (hasOldState && Object.prototype.hasOwnProperty.call(state, newName) &&
115
- JSON.stringify(state[oldName]) !== JSON.stringify(state[newName])) {
116
- throw new Error(
117
- `rename refused: tools.state.json already has an entry for "${newName}" that differs from ` +
118
- `"${oldName}"'s - resolve the curation collision first, then rename`
119
- );
120
- }
121
-
122
- // ── Phase B: WRITE (all validation passed) ──────────────────────────────────────────────────
123
- if (rewrites.length) {
124
- for (const h of rewrites) h.matcher = escapeRegex(newName);
125
- atomicWriteJson(hooksPath, manifest);
126
- }
127
- if (hasOldState) {
128
- state[newName] = state[oldName];
129
- delete state[oldName];
130
- atomicWriteJson(statePath, state);
131
- }
132
- }
133
-
134
- /**
135
- * Convenience for the two stores: derive the sibling config paths from the CONFIG-HOME layout
136
- * (<root>/mcp/expose.json, <root>/tools/tools.register.json, <root>/tools/tools.state.json,
137
- * <root>/hooks/hooks.manifest.json) and migrate. `storeFilePath` is the calling store's own
138
- * file; its grandparent is the home root.
139
- *
140
- * `opts.migrateState` (default TRUE) selects whether the tools.state.json key moves with the
141
- * name. The gate matcher ALWAYS moves - it genuinely fires on the surfaced name for both stores.
142
- * The state key does NOT, because the overlay has TWO key namespaces:
143
- * - UPSTREAM tools are keyed by their SURFACED name (server.js:638,733,924; ui:1273) - an
144
- * expose `as` edit changes that key, so ExposeStore migrates state (the default).
145
- * - LOCAL register tools are keyed by their register ID (server.js:290,337,814-815,952;
146
- * ui:326-328; tf-tool-set.js) - an id is immutable and a display-name edit does not touch
147
- * it, so Registry passes migrateState:false. Moving the key there would ORPHAN the entry:
148
- * the readers still look up the id, find nothing, and fall back to the defaults - which are
149
- * enabled - so a tool deliberately switched off would come back on (rename-safety S5).
150
- * Bites whenever name === id, i.e. every tf_* management tool.
151
- */
152
- function migrateForStoreFile(storeFilePath, oldName, newName, opts) {
153
- const root = path.dirname(path.dirname(path.resolve(storeFilePath)));
154
- const migrateState = !(opts && opts.migrateState === false);
155
- migrateSurfacedName({
156
- hooksPath: path.join(root, 'hooks', 'hooks.manifest.json'),
157
- statePath: migrateState ? path.join(root, 'tools', 'tools.state.json') : null,
158
- oldName,
159
- newName,
160
- });
161
- }
162
-
163
- module.exports = { migrateSurfacedName, migrateForStoreFile, escapeRegex };
1
+ 'use strict';
2
+
3
+ /**
4
+ * rename-migration.js - keep gates and per-tool state attached across a SURFACED-NAME change
5
+ * (0.7.0; the rename-safety.test.js invariants are the acceptance).
6
+ *
7
+ * A tool's SURFACED name is the string the PreToolUse gate matches and the tools.state.json
8
+ * key is written against. 0.6.0 let that name change (an expose `as` edit, an enabled flip, a
9
+ * local display-name edit) while the gate matcher and the state key silently kept the OLD
10
+ * string - a deny gate stopped firing and a disabled tool resurrected. The policy here,
11
+ * MATCHER-MIGRATE-ON-RENAME:
12
+ *
13
+ * - A WILDCARD matcher ('', '*', unset) fires for every tool name - it already covers the
14
+ * new name; untouched.
15
+ * - An EXACT-LITERAL matcher - the `escapeRegex(oldName)` shape every ToolFunnel surface
16
+ * (tf_* tools, the UI) writes - is REWRITTEN to `escapeRegex(newName)`.
17
+ * - Any OTHER matcher that would fire for the old name (same anchored-regex semantics as
18
+ * core/matcher.js, the gate's own matching) is one WE DID NOT AUTHOR and cannot safely
19
+ * rewrite: the rename is REFUSED with the hook named. Update the hook first, then rename.
20
+ * - tools.state.json: the old key's entry (enabled/hidden/hot) MOVES to the new key. A
21
+ * collision (both keys present with different content) refuses - never clobber curation.
22
+ *
23
+ * Validation runs COMPLETELY before the first write, and the caller invokes this BEFORE
24
+ * persisting its own rename - a refusal leaves every file untouched.
25
+ *
26
+ * CommonJS only. Node built-ins + core/matcher only (core/ stays headless).
27
+ */
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+ const { matches, matcherError } = require('./matcher');
32
+
33
+ /** Regex-escape a literal so a matcher built from it FULL-matches the string verbatim.
34
+ * (Same shape as ui/server.js escapeRegex - the shape ToolFunnel-authored matchers use.) */
35
+ function escapeRegex(str) {
36
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
+ }
38
+
39
+ /** Copied exactly from registry.js::atomicWriteJson - crash leaves old file or new, never half. */
40
+ function atomicWriteJson(targetPath, obj) {
41
+ const dir = path.dirname(targetPath);
42
+ const base = path.basename(targetPath);
43
+ const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
44
+ const data = JSON.stringify(obj, null, 2) + '\n';
45
+ const fd = fs.openSync(tmp, 'w');
46
+ try {
47
+ fs.writeSync(fd, data, 0, 'utf8');
48
+ fs.fsyncSync(fd);
49
+ } finally {
50
+ fs.closeSync(fd);
51
+ }
52
+ fs.renameSync(tmp, targetPath);
53
+ }
54
+
55
+ function readJsonIfPresent(p) {
56
+ try {
57
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
58
+ } catch (_e) {
59
+ return null; // absent or unreadable -> nothing to migrate there
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Migrate every gate matcher and state key from oldName to newName, or throw a refusal.
65
+ * @param {object} o
66
+ * @param {string} o.hooksPath hooks.manifest.json path (may be absent on disk)
67
+ * @param {string} o.statePath tools.state.json path (may be absent on disk)
68
+ * @param {string} o.oldName the surfaced name being retired
69
+ * @param {string} o.newName the surfaced name taking over
70
+ */
71
+ function migrateSurfacedName(o) {
72
+ const { hooksPath, statePath, oldName, newName } = o || {};
73
+ if (typeof oldName !== 'string' || typeof newName !== 'string' || oldName === newName) return;
74
+
75
+ // ── Phase A: VALIDATE everything, write nothing ─────────────────────────────────────────────
76
+ const manifest = hooksPath ? readJsonIfPresent(hooksPath) : null;
77
+ const hooks = manifest && Array.isArray(manifest.hooks) ? manifest.hooks : [];
78
+ const literal = escapeRegex(oldName);
79
+ const rewrites = [];
80
+ for (const h of hooks) {
81
+ if (!h || typeof h !== 'object') continue;
82
+ const m = h.matcher;
83
+ if (m === undefined || m === null || m === '' || m === '*') continue; // wildcard covers both names
84
+ if (m === literal || m === oldName) {
85
+ // The exact-literal shape ToolFunnel authors (escaped, or the bare name when it contains
86
+ // no regex specials - the two are identical then, but hand-written manifests may carry
87
+ // the bare form for a name that DOES contain specials, e.g. "my.tool").
88
+ rewrites.push(h);
89
+ continue;
90
+ }
91
+ // A matcher that does not COMPILE cannot be evaluated against oldName at all - matches()
92
+ // would answer false, silently carrying the rename past a hook this code cannot read.
93
+ // Same policy as a matching non-literal: refuse, name the hook, touch nothing.
94
+ // (addEntry refuses these at authoring, so only a hand-edited manifest reaches here.)
95
+ const compileProblem = matcherError(m);
96
+ if (compileProblem) {
97
+ throw new Error(
98
+ `rename refused: hook "${h.id || '(unnamed)'}" matcher ${JSON.stringify(m)} does not ` +
99
+ `compile (${compileProblem}) - it cannot be checked against "${oldName}", so the rename ` +
100
+ 'cannot proceed safely. Fix the hook first, then rename'
101
+ );
102
+ }
103
+ if (matches(m, oldName)) {
104
+ throw new Error(
105
+ `rename refused: hook "${h.id || '(unnamed)'}" matcher "${m}" matches "${oldName}" but is ` +
106
+ 'not an exact-literal matcher ToolFunnel can rewrite - update the hook to cover ' +
107
+ `"${newName}" first, then rename`
108
+ );
109
+ }
110
+ }
111
+
112
+ const state = statePath ? readJsonIfPresent(statePath) : null;
113
+ const hasOldState = !!(state && Object.prototype.hasOwnProperty.call(state, oldName));
114
+ if (hasOldState && Object.prototype.hasOwnProperty.call(state, newName) &&
115
+ JSON.stringify(state[oldName]) !== JSON.stringify(state[newName])) {
116
+ throw new Error(
117
+ `rename refused: tools.state.json already has an entry for "${newName}" that differs from ` +
118
+ `"${oldName}"'s - resolve the curation collision first, then rename`
119
+ );
120
+ }
121
+
122
+ // ── Phase B: WRITE (all validation passed) ──────────────────────────────────────────────────
123
+ if (rewrites.length) {
124
+ for (const h of rewrites) h.matcher = escapeRegex(newName);
125
+ atomicWriteJson(hooksPath, manifest);
126
+ }
127
+ if (hasOldState) {
128
+ state[newName] = state[oldName];
129
+ delete state[oldName];
130
+ atomicWriteJson(statePath, state);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Convenience for the two stores: derive the sibling config paths from the CONFIG-HOME layout
136
+ * (<root>/mcp/expose.json, <root>/tools/tools.register.json, <root>/tools/tools.state.json,
137
+ * <root>/hooks/hooks.manifest.json) and migrate. `storeFilePath` is the calling store's own
138
+ * file; its grandparent is the home root.
139
+ *
140
+ * `opts.migrateState` (default TRUE) selects whether the tools.state.json key moves with the
141
+ * name. The gate matcher ALWAYS moves - it genuinely fires on the surfaced name for both stores.
142
+ * The state key does NOT, because the overlay has TWO key namespaces:
143
+ * - UPSTREAM tools are keyed by their SURFACED name (server.js:638,733,924; ui:1273) - an
144
+ * expose `as` edit changes that key, so ExposeStore migrates state (the default).
145
+ * - LOCAL register tools are keyed by their register ID (server.js:290,337,814-815,952;
146
+ * ui:326-328; tf-tool-set.js) - an id is immutable and a display-name edit does not touch
147
+ * it, so Registry passes migrateState:false. Moving the key there would ORPHAN the entry:
148
+ * the readers still look up the id, find nothing, and fall back to the defaults - which are
149
+ * enabled - so a tool deliberately switched off would come back on (rename-safety S5).
150
+ * Bites whenever name === id, i.e. every tf_* management tool.
151
+ */
152
+ function migrateForStoreFile(storeFilePath, oldName, newName, opts) {
153
+ const root = path.dirname(path.dirname(path.resolve(storeFilePath)));
154
+ const migrateState = !(opts && opts.migrateState === false);
155
+ migrateSurfacedName({
156
+ hooksPath: path.join(root, 'hooks', 'hooks.manifest.json'),
157
+ statePath: migrateState ? path.join(root, 'tools', 'tools.state.json') : null,
158
+ oldName,
159
+ newName,
160
+ });
161
+ }
162
+
163
+ module.exports = { migrateSurfacedName, migrateForStoreFile, escapeRegex };
@@ -1,138 +1,138 @@
1
- 'use strict';
2
-
3
- /**
4
- * requires.js - runtime-requirement preflight (the packaging story's honesty layer).
5
- *
6
- * A shipped pack's tools may need runtimes the gateway itself does not (python for a PDF tool,
7
- * git for a repo tool). The pack DECLARES them in its toolfunnel.json - the identity file that
8
- * already travels with every config home - and the gateway probes them ONCE at startup:
9
- *
10
- * "requires": [
11
- * { "command": "python", "min": "3.10", "why": "the pdf-extract tools" },
12
- * { "command": "git" }
13
- * ]
14
- *
15
- * Probes are ADVISORY, never fatal: a missing runtime breaks only the tools that need it, so the
16
- * gateway starts and the operator gets a clear stderr line naming what is missing, the version
17
- * found vs needed, and the declared "why". Fields per entry (only `command` is required):
18
- * command the executable to probe (e.g. "python", "node", "git")
19
- * versionArg the flag that prints a version (default "--version")
20
- * min minimum version, dotted numerics (e.g. "3.10" / "18" / "3.10.2")
21
- * why shown in the warning so the operator knows which tools care
22
- *
23
- * Zero new dependencies: child_process probes + a hand-rolled dotted-numeric compare. NO semver
24
- * library - by the packaging hard constraint, and dotted numerics cover real runtime versions.
25
- */
26
-
27
- const fs = require('node:fs');
28
- const path = require('node:path');
29
- const { spawnSync } = require('node:child_process');
30
-
31
- /** Parse the FIRST dotted-numeric version out of a probe's output ("Python 3.12.1" -> [3,12,1]).
32
- * Returns null when no version-shaped token is present. */
33
- function parseVersion(text) {
34
- const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(text || ''));
35
- if (!m) {
36
- const single = /\b(\d+)\b/.exec(String(text || ''));
37
- return single ? [Number(single[1]), 0, 0] : null;
38
- }
39
- return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
40
- }
41
-
42
- /** Hand-rolled dotted-numeric compare: -1 | 0 | 1 for a<b | a==b | a>b. */
43
- function compareVersions(a, b) {
44
- for (let i = 0; i < 3; i += 1) {
45
- const x = a[i] || 0;
46
- const y = b[i] || 0;
47
- if (x < y) return -1;
48
- if (x > y) return 1;
49
- }
50
- return 0;
51
- }
52
-
53
- /**
54
- * Probe one command for its version string. On Windows a PATHEXT shim (a .cmd like npx) cannot be
55
- * spawned shell:false (ENOENT/EINVAL - the CVE-2024-27980 mitigation), so a failed direct probe is
56
- * retried through cmd.exe, mirroring mcp-client's winLaunch.
57
- * @param {string} command
58
- * @param {string} versionArg
59
- * @returns {{found: boolean, version: (number[]|null), raw: string}}
60
- */
61
- function probe(command, versionArg) {
62
- const runOnce = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true, timeout: 5000 });
63
- let res = runOnce(command, [versionArg]);
64
- if (res.error && process.platform === 'win32') {
65
- const comspec = process.env.ComSpec || 'cmd.exe';
66
- res = runOnce(comspec, ['/c', command, versionArg]);
67
- }
68
- if (res.error || res.status == null) return { found: false, version: null, raw: '' };
69
- const raw = `${res.stdout || ''} ${res.stderr || ''}`.trim();
70
- const version = parseVersion(raw);
71
- // "Spawned" is not "exists": the cmd.exe fallback launches fine for a NONEXISTENT command and
72
- // exits non-zero ("is not recognized..."). Existence = a clean exit OR a version-shaped answer.
73
- const found = res.status === 0 || version !== null;
74
- return { found, version: found ? version : null, raw };
75
- }
76
-
77
- /**
78
- * Check the `requires` declared in `<home>/toolfunnel.json`. Returns the problem list - the
79
- * caller decides how to surface it (the gateway logs each to stderr and starts anyway). NEVER
80
- * throws; a malformed requires entry is itself reported as a problem rather than crashing.
81
- * @param {string} home the resolved config home
82
- * @returns {Array<{command:string, problem:string}>}
83
- */
84
- function checkRequires(home) {
85
- let cfg;
86
- try {
87
- cfg = JSON.parse(fs.readFileSync(path.join(home, 'toolfunnel.json'), 'utf8'));
88
- } catch (_e) {
89
- return []; // absent/bad identity file -> nothing declared
90
- }
91
- const list = cfg && Array.isArray(cfg.requires) ? cfg.requires : [];
92
- const problems = [];
93
- for (const entry of list) {
94
- if (!entry || typeof entry !== 'object' || typeof entry.command !== 'string' || entry.command.trim() === '') {
95
- problems.push({ command: String(entry && entry.command), problem: 'malformed requires entry (needs a "command" string)' });
96
- continue;
97
- }
98
- const command = entry.command.trim();
99
- // A requires command is a bare PROGRAM NAME, never a shell string. This is load-bearing on
100
- // Windows: the cmd.exe shim fallback below hands the token to cmd, where an unquoted
101
- // metacharacter ("x&denied") would CHAIN commands - a probe must never be able to run anything
102
- // beyond `<program> <versionArg>`. Reject anything shell-shaped up front (also catches paths:
103
- // declare tools by PATH name, which is the only portable claim a pack can make anyway).
104
- if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(command)) {
105
- problems.push({ command, problem: `requires.command "${command}" is not a bare program name (no paths, spaces, or shell characters)` });
106
- continue;
107
- }
108
- const why = typeof entry.why === 'string' && entry.why ? ` (needed for: ${entry.why})` : '';
109
- const versionArg = typeof entry.versionArg === 'string' && /^--?[A-Za-z][A-Za-z-]*$/.test(entry.versionArg)
110
- ? entry.versionArg
111
- : '--version'; // same rule: a version FLAG, never a shell fragment
112
- const p = probe(command, versionArg);
113
- if (!p.found) {
114
- problems.push({ command, problem: `"${command}" was not found on PATH${why}` });
115
- continue;
116
- }
117
- if (typeof entry.min === 'string' && entry.min.trim() !== '') {
118
- const min = parseVersion(entry.min);
119
- if (!min) {
120
- problems.push({ command, problem: `requires.min "${entry.min}" is not a dotted numeric version` });
121
- continue;
122
- }
123
- if (!p.version) {
124
- problems.push({ command, problem: `"${command}" answered but printed no recognisable version (wanted >= ${entry.min})${why}` });
125
- continue;
126
- }
127
- if (compareVersions(p.version, min) < 0) {
128
- problems.push({
129
- command,
130
- problem: `"${command}" is ${p.version.join('.')} but this setup declares >= ${entry.min}${why}`,
131
- });
132
- }
133
- }
134
- }
135
- return problems;
136
- }
137
-
138
- module.exports = { checkRequires, parseVersion, compareVersions, probe };
1
+ 'use strict';
2
+
3
+ /**
4
+ * requires.js - runtime-requirement preflight (the packaging story's honesty layer).
5
+ *
6
+ * A shipped pack's tools may need runtimes the gateway itself does not (python for a PDF tool,
7
+ * git for a repo tool). The pack DECLARES them in its toolfunnel.json - the identity file that
8
+ * already travels with every config home - and the gateway probes them ONCE at startup:
9
+ *
10
+ * "requires": [
11
+ * { "command": "python", "min": "3.10", "why": "the pdf-extract tools" },
12
+ * { "command": "git" }
13
+ * ]
14
+ *
15
+ * Probes are ADVISORY, never fatal: a missing runtime breaks only the tools that need it, so the
16
+ * gateway starts and the operator gets a clear stderr line naming what is missing, the version
17
+ * found vs needed, and the declared "why". Fields per entry (only `command` is required):
18
+ * command the executable to probe (e.g. "python", "node", "git")
19
+ * versionArg the flag that prints a version (default "--version")
20
+ * min minimum version, dotted numerics (e.g. "3.10" / "18" / "3.10.2")
21
+ * why shown in the warning so the operator knows which tools care
22
+ *
23
+ * Zero new dependencies: child_process probes + a hand-rolled dotted-numeric compare. NO semver
24
+ * library - by the packaging hard constraint, and dotted numerics cover real runtime versions.
25
+ */
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+ const { spawnSync } = require('node:child_process');
30
+
31
+ /** Parse the FIRST dotted-numeric version out of a probe's output ("Python 3.12.1" -> [3,12,1]).
32
+ * Returns null when no version-shaped token is present. */
33
+ function parseVersion(text) {
34
+ const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(text || ''));
35
+ if (!m) {
36
+ const single = /\b(\d+)\b/.exec(String(text || ''));
37
+ return single ? [Number(single[1]), 0, 0] : null;
38
+ }
39
+ return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
40
+ }
41
+
42
+ /** Hand-rolled dotted-numeric compare: -1 | 0 | 1 for a<b | a==b | a>b. */
43
+ function compareVersions(a, b) {
44
+ for (let i = 0; i < 3; i += 1) {
45
+ const x = a[i] || 0;
46
+ const y = b[i] || 0;
47
+ if (x < y) return -1;
48
+ if (x > y) return 1;
49
+ }
50
+ return 0;
51
+ }
52
+
53
+ /**
54
+ * Probe one command for its version string. On Windows a PATHEXT shim (a .cmd like npx) cannot be
55
+ * spawned shell:false (ENOENT/EINVAL - the CVE-2024-27980 mitigation), so a failed direct probe is
56
+ * retried through cmd.exe, mirroring mcp-client's winLaunch.
57
+ * @param {string} command
58
+ * @param {string} versionArg
59
+ * @returns {{found: boolean, version: (number[]|null), raw: string}}
60
+ */
61
+ function probe(command, versionArg) {
62
+ const runOnce = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true, timeout: 5000 });
63
+ let res = runOnce(command, [versionArg]);
64
+ if (res.error && process.platform === 'win32') {
65
+ const comspec = process.env.ComSpec || 'cmd.exe';
66
+ res = runOnce(comspec, ['/c', command, versionArg]);
67
+ }
68
+ if (res.error || res.status == null) return { found: false, version: null, raw: '' };
69
+ const raw = `${res.stdout || ''} ${res.stderr || ''}`.trim();
70
+ const version = parseVersion(raw);
71
+ // "Spawned" is not "exists": the cmd.exe fallback launches fine for a NONEXISTENT command and
72
+ // exits non-zero ("is not recognized..."). Existence = a clean exit OR a version-shaped answer.
73
+ const found = res.status === 0 || version !== null;
74
+ return { found, version: found ? version : null, raw };
75
+ }
76
+
77
+ /**
78
+ * Check the `requires` declared in `<home>/toolfunnel.json`. Returns the problem list - the
79
+ * caller decides how to surface it (the gateway logs each to stderr and starts anyway). NEVER
80
+ * throws; a malformed requires entry is itself reported as a problem rather than crashing.
81
+ * @param {string} home the resolved config home
82
+ * @returns {Array<{command:string, problem:string}>}
83
+ */
84
+ function checkRequires(home) {
85
+ let cfg;
86
+ try {
87
+ cfg = JSON.parse(fs.readFileSync(path.join(home, 'toolfunnel.json'), 'utf8'));
88
+ } catch (_e) {
89
+ return []; // absent/bad identity file -> nothing declared
90
+ }
91
+ const list = cfg && Array.isArray(cfg.requires) ? cfg.requires : [];
92
+ const problems = [];
93
+ for (const entry of list) {
94
+ if (!entry || typeof entry !== 'object' || typeof entry.command !== 'string' || entry.command.trim() === '') {
95
+ problems.push({ command: String(entry && entry.command), problem: 'malformed requires entry (needs a "command" string)' });
96
+ continue;
97
+ }
98
+ const command = entry.command.trim();
99
+ // A requires command is a bare PROGRAM NAME, never a shell string. This is load-bearing on
100
+ // Windows: the cmd.exe shim fallback below hands the token to cmd, where an unquoted
101
+ // metacharacter ("x&denied") would CHAIN commands - a probe must never be able to run anything
102
+ // beyond `<program> <versionArg>`. Reject anything shell-shaped up front (also catches paths:
103
+ // declare tools by PATH name, which is the only portable claim a pack can make anyway).
104
+ if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(command)) {
105
+ problems.push({ command, problem: `requires.command "${command}" is not a bare program name (no paths, spaces, or shell characters)` });
106
+ continue;
107
+ }
108
+ const why = typeof entry.why === 'string' && entry.why ? ` (needed for: ${entry.why})` : '';
109
+ const versionArg = typeof entry.versionArg === 'string' && /^--?[A-Za-z][A-Za-z-]*$/.test(entry.versionArg)
110
+ ? entry.versionArg
111
+ : '--version'; // same rule: a version FLAG, never a shell fragment
112
+ const p = probe(command, versionArg);
113
+ if (!p.found) {
114
+ problems.push({ command, problem: `"${command}" was not found on PATH${why}` });
115
+ continue;
116
+ }
117
+ if (typeof entry.min === 'string' && entry.min.trim() !== '') {
118
+ const min = parseVersion(entry.min);
119
+ if (!min) {
120
+ problems.push({ command, problem: `requires.min "${entry.min}" is not a dotted numeric version` });
121
+ continue;
122
+ }
123
+ if (!p.version) {
124
+ problems.push({ command, problem: `"${command}" answered but printed no recognisable version (wanted >= ${entry.min})${why}` });
125
+ continue;
126
+ }
127
+ if (compareVersions(p.version, min) < 0) {
128
+ problems.push({
129
+ command,
130
+ problem: `"${command}" is ${p.version.join('.')} but this setup declares >= ${entry.min}${why}`,
131
+ });
132
+ }
133
+ }
134
+ }
135
+ return problems;
136
+ }
137
+
138
+ module.exports = { checkRequires, parseVersion, compareVersions, probe };