toolfunnel 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +238 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +530 -451
  4. package/docs/KNOWN_BUGS.md +100 -80
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -84
  7. package/docs/packaging.md +116 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -39
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +379 -380
  14. package/package.json +67 -67
  15. package/src/auth/config.js +172 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +358 -353
  18. package/src/core/atomic-write.js +97 -0
  19. package/src/core/child-reaper.js +61 -61
  20. package/src/core/config-home.js +226 -134
  21. package/src/core/events.js +105 -105
  22. package/src/core/hook-engine.js +304 -329
  23. package/src/core/hook-loader.js +766 -824
  24. package/src/core/hook-runner.js +615 -566
  25. package/src/core/logger.js +295 -297
  26. package/src/core/matcher.js +100 -100
  27. package/src/core/metrics.js +90 -75
  28. package/src/core/output-cap.js +31 -0
  29. package/src/core/rename-migration.js +178 -163
  30. package/src/core/requires.js +138 -138
  31. package/src/core/server-config.js +98 -98
  32. package/src/extend/add-hook.md +155 -152
  33. package/src/extend/add-mcp.md +119 -119
  34. package/src/extend/configure.md +88 -88
  35. package/src/extend/create-tool.md +168 -168
  36. package/src/extend/howto.js +102 -102
  37. package/src/extend/package.md +190 -190
  38. package/src/extend/wrap.md +99 -99
  39. package/src/mcp/aggregator.js +2248 -1674
  40. package/src/mcp/expose-store.js +755 -577
  41. package/src/mcp/gated-run.js +210 -210
  42. package/src/mcp/http-transport.js +1657 -1563
  43. package/src/mcp/mcp-client.js +1174 -1070
  44. package/src/mcp/modern.js +409 -406
  45. package/src/mcp/protocol.js +473 -468
  46. package/src/mcp/server.js +3317 -3112
  47. package/src/mcp/tool-approvals.js +396 -0
  48. package/src/tools/registry.js +607 -661
  49. package/src/tools/tool-state.js +170 -170
  50. package/src/ui/public/app.js +1246 -1187
  51. package/src/ui/public/index.html +286 -286
  52. package/src/ui/public/styles.css +535 -535
  53. package/src/ui/server.js +1742 -1616
  54. package/tools/scripts/base64.js +139 -139
  55. package/tools/scripts/danger.js +61 -61
  56. package/tools/scripts/echo.js +39 -39
  57. package/tools/scripts/hash.js +101 -101
  58. package/tools/scripts/json.js +109 -109
  59. package/tools/scripts/text-stats.js +131 -131
  60. package/tools/scripts/tf-accept-change.js +67 -0
  61. package/tools/scripts/tf-env.js +43 -43
  62. package/tools/scripts/tf-hook-add.js +151 -151
  63. package/tools/scripts/tf-hook-set.js +120 -120
  64. package/tools/scripts/tf-list.js +192 -192
  65. package/tools/scripts/tf-log.js +86 -86
  66. package/tools/scripts/tf-mcp-add.js +130 -117
  67. package/tools/scripts/tf-mcp-set.js +121 -121
  68. package/tools/scripts/tf-pack.js +237 -237
  69. package/tools/scripts/tf-tool-add.js +134 -134
  70. package/tools/scripts/tf-tool-set.js +142 -142
  71. package/tools/scripts/tf-wrap.js +103 -103
  72. package/tools/scripts/uuid.js +102 -102
  73. package/tools/tools.register.json +16 -5
@@ -1,163 +1,178 @@
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
+ *
6
+ * A tool's SURFACED name is the string the PreToolUse gate matches and the tools.state.json
7
+ * key is written against. When that name changes (an expose `as` edit, an enabled flip, a
8
+ * local display-name edit) and the gate matcher and the state key keep the OLD string, a deny
9
+ * gate stops firing and a disabled tool comes back. The policy here,
10
+ * MATCHER-MIGRATE-ON-RENAME:
11
+ *
12
+ * - A WILDCARD matcher ('', '*', unset) fires for every tool name - it already covers the
13
+ * new name; untouched.
14
+ * - An EXACT-LITERAL matcher - the `escapeRegex(oldName)` shape every ToolFunnel surface
15
+ * (tf_* tools, the UI) writes - is REWRITTEN to `escapeRegex(newName)`.
16
+ * - Any OTHER matcher that would fire for the old name (same anchored-regex semantics as
17
+ * core/matcher.js, the gate's own matching) is one WE DID NOT AUTHOR and cannot safely
18
+ * rewrite: the rename is REFUSED with the hook named. Update the hook first, then rename.
19
+ * - tools.state.json: the old key's entry (enabled/hidden/hot) MOVES to the new key. A
20
+ * collision (both keys present with different content) refuses - never clobber curation.
21
+ *
22
+ * Validation runs COMPLETELY before the first write, and the caller invokes this BEFORE
23
+ * persisting its own rename - a refusal leaves every file untouched.
24
+ *
25
+ * CommonJS only. Node built-ins + core/matcher only (core/ stays headless).
26
+ */
27
+
28
+ const fs = require('node:fs');
29
+ const path = require('node:path');
30
+ const { matches, matcherError } = require('./matcher');
31
+
32
+ /** Regex-escape a literal so a matcher built from it FULL-matches the string verbatim.
33
+ * (Same shape as ui/server.js escapeRegex - the shape ToolFunnel-authored matchers use.) */
34
+ function escapeRegex(str) {
35
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ }
37
+
38
+ // THE atomic writer (temp + fsync + rename, failure-path unlink, stale sweep) - shared by every store.
39
+ // This file's own copy had neither the unlink nor the sweep.
40
+ const { atomicWriteJson, atomicWriteText } = require('./atomic-write');
41
+
42
+ function readJsonIfPresent(p) {
43
+ try {
44
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
45
+ } catch (_e) {
46
+ return null; // absent or unreadable -> nothing to migrate there
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Migrate every gate matcher and state key from oldName to newName, or throw a refusal.
52
+ * @param {object} o
53
+ * @param {string} o.hooksPath hooks.manifest.json path (may be absent on disk)
54
+ * @param {string} o.statePath tools.state.json path (may be absent on disk)
55
+ * @param {string} o.oldName the surfaced name being retired
56
+ * @param {string} o.newName the surfaced name taking over
57
+ */
58
+ function migrateSurfacedName(o) {
59
+ const { hooksPath, statePath, oldName, newName } = o || {};
60
+ if (typeof oldName !== 'string' || typeof newName !== 'string' || oldName === newName) return;
61
+
62
+ // ── Phase A: VALIDATE everything, write nothing ─────────────────────────────────────────────
63
+ const manifest = hooksPath ? readJsonIfPresent(hooksPath) : null;
64
+ const hooks = manifest && Array.isArray(manifest.hooks) ? manifest.hooks : [];
65
+ const literal = escapeRegex(oldName);
66
+ const rewrites = [];
67
+ for (const h of hooks) {
68
+ if (!h || typeof h !== 'object') continue;
69
+ const m = h.matcher;
70
+ if (m === undefined || m === null || m === '' || m === '*') continue; // wildcard covers both names
71
+ if (m === literal || m === oldName) {
72
+ // The exact-literal shape ToolFunnel authors (escaped, or the bare name when it contains
73
+ // no regex specials - the two are identical then, but hand-written manifests may carry
74
+ // the bare form for a name that DOES contain specials, e.g. "my.tool").
75
+ rewrites.push(h);
76
+ continue;
77
+ }
78
+ // A matcher that does not COMPILE cannot be evaluated against oldName at all - matches()
79
+ // would answer false, silently carrying the rename past a hook this code cannot read.
80
+ // Same policy as a matching non-literal: refuse, name the hook, touch nothing.
81
+ // (addEntry refuses these at authoring, so only a hand-edited manifest reaches here.)
82
+ const compileProblem = matcherError(m);
83
+ if (compileProblem) {
84
+ throw new Error(
85
+ `rename refused: hook "${h.id || '(unnamed)'}" matcher ${JSON.stringify(m)} does not ` +
86
+ `compile (${compileProblem}) - it cannot be checked against "${oldName}", so the rename ` +
87
+ 'cannot proceed safely. Fix the hook first, then rename'
88
+ );
89
+ }
90
+ if (matches(m, oldName)) {
91
+ throw new Error(
92
+ `rename refused: hook "${h.id || '(unnamed)'}" matcher "${m}" matches "${oldName}" but is ` +
93
+ 'not an exact-literal matcher ToolFunnel can rewrite - update the hook to cover ' +
94
+ `"${newName}" first, then rename`
95
+ );
96
+ }
97
+ }
98
+
99
+ const state = statePath ? readJsonIfPresent(statePath) : null;
100
+ const hasOldState = !!(state && Object.prototype.hasOwnProperty.call(state, oldName));
101
+ if (hasOldState && Object.prototype.hasOwnProperty.call(state, newName) &&
102
+ JSON.stringify(state[oldName]) !== JSON.stringify(state[newName])) {
103
+ throw new Error(
104
+ `rename refused: tools.state.json already has an entry for "${newName}" that differs from ` +
105
+ `"${oldName}"'s - resolve the curation collision first, then rename`
106
+ );
107
+ }
108
+
109
+ // ── Phase B: WRITE (all validation passed) ──────────────────────────────────────────────────
110
+ if (rewrites.length) {
111
+ for (const h of rewrites) h.matcher = escapeRegex(newName);
112
+ atomicWriteJson(hooksPath, manifest);
113
+ }
114
+ if (hasOldState) {
115
+ state[newName] = state[oldName];
116
+ delete state[oldName];
117
+ atomicWriteJson(statePath, state);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Convenience for the two stores: derive the sibling config paths from the CONFIG-HOME layout
123
+ * (<root>/mcp/expose.json, <root>/tools/tools.register.json, <root>/tools/tools.state.json,
124
+ * <root>/hooks/hooks.manifest.json) and migrate. `storeFilePath` is the calling store's own
125
+ * file; its grandparent is the home root.
126
+ *
127
+ * `opts.migrateState` (default TRUE) selects whether the tools.state.json key moves with the
128
+ * name. The gate matcher ALWAYS moves - it genuinely fires on the surfaced name for both stores.
129
+ * The state key does NOT, because the overlay has TWO key namespaces:
130
+ * - UPSTREAM tools are keyed by their SURFACED name (server.js:638,733,924; ui:1273) - an
131
+ * expose `as` edit changes that key, so ExposeStore migrates state (the default).
132
+ * - LOCAL register tools are keyed by their register ID (server.js:290,337,814-815,952;
133
+ * ui:326-328; tf-tool-set.js) - an id is immutable and a display-name edit does not touch
134
+ * it, so Registry passes migrateState:false. Moving the key there would ORPHAN the entry:
135
+ * the readers still look up the id, find nothing, and fall back to the defaults - which are
136
+ * enabled - so a tool deliberately switched off would come back on (rename-safety S5).
137
+ * Bites whenever name === id, i.e. every tf_* management tool.
138
+ */
139
+ function migrateForStoreFile(storeFilePath, oldName, newName, opts) {
140
+ const root = path.dirname(path.dirname(path.resolve(storeFilePath)));
141
+ const migrateState = !(opts && opts.migrateState === false);
142
+ migrateSurfacedName({
143
+ hooksPath: path.join(root, 'hooks', 'hooks.manifest.json'),
144
+ statePath: migrateState ? path.join(root, 'tools', 'tools.state.json') : null,
145
+ oldName,
146
+ newName,
147
+ });
148
+ }
149
+
150
+ /**
151
+ * The sibling config files a migration may write, derived as migrateForStoreFile does (the store's
152
+ * grandparent is the config-home root). Exported so a caller can snapshot them BEFORE a migration and
153
+ * restore them if the write that follows fails - re-running a migration in reverse is not an unwind:
154
+ * it re-validates, and a pattern hook that matches the NEW name refuses it, leaving the manifest
155
+ * naming a surface the store does not (rename-rollback.test.js, case A2).
156
+ */
157
+ function snapshotForStoreFile(storeFilePath) {
158
+ const root = path.dirname(path.dirname(storeFilePath));
159
+ const files = [path.join(root, 'hooks', 'hooks.manifest.json'), path.join(root, 'tools', 'tools.state.json')];
160
+ const snap = [];
161
+ for (const f of files) {
162
+ let bytes = null;
163
+ try { bytes = fs.readFileSync(f); } catch (_e) { bytes = null; } // absent stays absent
164
+ snap.push({ path: f, bytes });
165
+ }
166
+ return snap;
167
+ }
168
+
169
+ /** Put the snapshotted bytes back exactly (absent files are removed again). Best-effort, never throws. */
170
+ function restoreSnapshot(snap) {
171
+ for (const { path: f, bytes } of snap || []) {
172
+ try {
173
+ if (bytes == null) { if (fs.existsSync(f)) fs.unlinkSync(f); } else { atomicWriteText(f, bytes); }
174
+ } catch (_e) { /* best-effort - the caller's own error is the story */ }
175
+ }
176
+ }
177
+
178
+ module.exports = { migrateSurfacedName, migrateForStoreFile, escapeRegex, snapshotForStoreFile, restoreSnapshot };