toolfunnel 0.6.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 (72) hide show
  1. package/README.md +228 -231
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -372
  4. package/docs/KNOWN_BUGS.md +80 -99
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -70
  7. package/docs/packaging.md +115 -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 -31
  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 +380 -350
  14. package/package.json +3 -1
  15. package/src/auth/config.js +174 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +353 -344
  18. package/src/core/child-reaper.js +61 -0
  19. package/src/core/config-home.js +134 -134
  20. package/src/core/events.js +105 -105
  21. package/src/core/hook-engine.js +329 -275
  22. package/src/core/hook-loader.js +824 -791
  23. package/src/core/hook-runner.js +566 -346
  24. package/src/core/logger.js +297 -171
  25. package/src/core/matcher.js +100 -51
  26. package/src/core/metrics.js +75 -75
  27. package/src/core/rename-migration.js +163 -0
  28. package/src/core/requires.js +138 -138
  29. package/src/core/server-config.js +98 -98
  30. package/src/extend/add-hook.md +152 -152
  31. package/src/extend/add-mcp.md +119 -122
  32. package/src/extend/configure.md +88 -87
  33. package/src/extend/create-tool.md +168 -168
  34. package/src/extend/howto.js +102 -102
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -99
  37. package/src/mcp/aggregator.js +1719 -1246
  38. package/src/mcp/expose-store.js +577 -460
  39. package/src/mcp/gated-run.js +210 -201
  40. package/src/mcp/http-transport.js +1563 -1311
  41. package/src/mcp/mcp-client.js +1070 -907
  42. package/src/mcp/modern.js +406 -372
  43. package/src/mcp/protocol.js +468 -468
  44. package/src/mcp/server.js +3112 -2688
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -170
  47. package/src/ui/public/app.js +1187 -1174
  48. package/src/ui/public/index.html +286 -286
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1348
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +120 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -121
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -134
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -103
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -227
  70. package/src/core/tool-registry.js +0 -180
  71. package/src/packages/packager.js +0 -715
  72. package/src/tools/drift.js +0 -138
@@ -1,170 +1,170 @@
1
- 'use strict';
2
-
3
- /**
4
- * tool-state.js - per-tool ACTIVE/DISABLED overlay for the register.
5
- *
6
- * Mirrors a sibling state overlay (see a config file): a small
7
- * overlay keyed by tool id, written atomically, loaded on startup -> survives
8
- * close/detach (persistent session-to-session). The register (tools.register.json)
9
- * holds ALL tools; this overlay decides which are surfaced to toolfunnel_list_tools.
10
- *
11
- * DEFAULT ON: a tool absent from the overlay is ACTIVE. Only an explicit
12
- * { enabled: false } disables it. So newly-added tools are active until you
13
- * untick them - and the file only ever lists the tools you've toggled.
14
- *
15
- * THREE independent axes (the visibility MATRIX), all keyed by the surfaced name
16
- * (a local tool id, OR an upstream's surfaced name `<upstream>_<tool>` / its `as`,
17
- * OR a meta-tool name like `toolfunnel_list_tools`):
18
- * - enabled : LEAN-VISIBLE - surfaced by toolfunnel_list_tools AND runnable. Default ON.
19
- * - hidden : manager-list declutter only (UI/tf_list). Default OFF. INDEPENDENT of enabled.
20
- * - hot : promoted to the TOP-LEVEL tools/list (injected EVERY turn). Default depends on
21
- * the tool KIND, supplied by the caller via isToolHot(state,id,defaultHot):
22
- * meta-tools default hot:true (the management surface), local/upstream default
23
- * hot:false (opt-in promotion). A disabled tool is never hot.
24
- *
25
- * Shape of tools.state.json: { "<id>": { "enabled": false, "hidden": true, "hot": true }, ... }
26
- *
27
- * PASSTHROUGH (0.6.0): one RESERVED top-level key, `"passthrough": "<upstreamId>"` (a STRING,
28
- * unambiguous against the per-tool object entries). When set, the gateway becomes a transparent
29
- * wrapper for that one upstream: its tools ARE the advertised surface (real schemas, implicitly
30
- * hot), the meta-tools and everything else are hidden and uncallable, and every call still fires
31
- * the PreToolUse gate. An explicit `enabled:false` on an upstream tool is still honoured (the
32
- * safety off-switch survives the wrap). Set/cleared by `toolfunnel wrap <id>` / `wrap --off`.
33
- *
34
- * CommonJS, Node built-ins only. Reuses registry.js's atomicWriteJson (temp+rename).
35
- */
36
-
37
- const fs = require('node:fs');
38
- const { atomicWriteJson } = require('./registry');
39
-
40
- /** Load the overlay ({} if missing/malformed - never throws). */
41
- function loadToolState(statePath) {
42
- try {
43
- const d = JSON.parse(fs.readFileSync(statePath, 'utf8'));
44
- return (d && typeof d === 'object' && !Array.isArray(d)) ? d : {};
45
- } catch (_e) {
46
- return {};
47
- }
48
- }
49
-
50
- /**
51
- * Like loadToolState, but distinguishes "file MISSING" (defaults are correct) from "file PRESENT
52
- * but unparseable / not an object" (a corrupt overlay - the caller may want to fail closed or warn
53
- * rather than silently serve defaults). Returns { state, parseError }. `parseError` is true ONLY
54
- * when the file exists but could not be read as a JSON object. NEVER throws.
55
- * @param {string} statePath
56
- * @returns {{ state: object, parseError: boolean }}
57
- */
58
- function loadToolStateResult(statePath) {
59
- let raw;
60
- try {
61
- raw = fs.readFileSync(statePath, 'utf8');
62
- } catch (_e) {
63
- return { state: {}, parseError: false }; // missing (or unreadable) -> defaults, not a corruption
64
- }
65
- try {
66
- const d = JSON.parse(raw);
67
- if (d && typeof d === 'object' && !Array.isArray(d)) return { state: d, parseError: false };
68
- return { state: {}, parseError: true }; // present but not a JSON object
69
- } catch (_e) {
70
- return { state: {}, parseError: true }; // present but not valid JSON
71
- }
72
- }
73
-
74
- /** Default ON: enabled unless the overlay explicitly says enabled:false. */
75
- function isToolEnabled(state, id) {
76
- const e = state && state[id];
77
- return !(e && e.enabled === false);
78
- }
79
-
80
- /** Default NOT hidden: hidden only when the overlay explicitly says hidden:true.
81
- * `hidden` is a manager-list-only flag (declutter); it is INDEPENDENT of `enabled`
82
- * (MCP visibility). A tool can be active-but-hidden, or disabled-but-visible. */
83
- function isToolHidden(state, id) {
84
- const e = state && state[id];
85
- return !!(e && e.hidden === true);
86
- }
87
-
88
- /** Default OFF for local/upstream, ON for meta - the caller supplies the kind-appropriate
89
- * `defaultHot`. An explicit boolean in the overlay always wins; absence falls back to the
90
- * default. `hot` is the TOP-LEVEL (every-turn) promotion axis - INDEPENDENT of enabled/hidden,
91
- * though a disabled tool is filtered out of the top-level surface by the assembler, not here. */
92
- function isToolHot(state, id, defaultHot) {
93
- const e = state && state[id];
94
- if (e && typeof e.hot === 'boolean') return e.hot;
95
- return !!defaultHot;
96
- }
97
-
98
- /** The one top-level key reserved for the passthrough wrap (a STRING value). A per-tool overlay
99
- * entry may NEVER use this key, else a per-tool toggle would clobber (or be clobbered by) the wrap
100
- * - they share the same flat object. The per-tool setters reject
101
- * it; a tool whose surfaced name is literally "passthrough" simply cannot carry an overlay entry
102
- * (it stays at defaults - enabled, not hot, not hidden), which is a fine, safe degradation. */
103
- const RESERVED_PASSTHROUGH_KEY = 'passthrough';
104
- function assertNotReserved(id, fn) {
105
- if (id === RESERVED_PASSTHROUGH_KEY) {
106
- throw new Error(`${fn}: "${RESERVED_PASSTHROUGH_KEY}" is a reserved state key (the passthrough wrap) and cannot be a per-tool id`);
107
- }
108
- }
109
-
110
- /** Set a tool's enabled flag, MERGING (so `hidden`/`hot` are preserved). Persists atomically. */
111
- function setToolEnabled(statePath, id, enabled) {
112
- if (typeof id !== 'string' || !id) throw new Error('setToolEnabled: id required');
113
- assertNotReserved(id, 'setToolEnabled');
114
- const s = loadToolState(statePath);
115
- s[id] = { ...(s[id] || {}), enabled: !!enabled };
116
- atomicWriteJson(statePath, s);
117
- return s;
118
- }
119
-
120
- /** Set a tool's hot flag, MERGING (so `enabled`/`hidden` are preserved). Persists atomically. */
121
- function setToolHot(statePath, id, hot) {
122
- if (typeof id !== 'string' || !id) throw new Error('setToolHot: id required');
123
- assertNotReserved(id, 'setToolHot');
124
- const s = loadToolState(statePath);
125
- s[id] = { ...(s[id] || {}), hot: !!hot };
126
- atomicWriteJson(statePath, s);
127
- return s;
128
- }
129
-
130
- /** Set a tool's hidden flag, MERGING (so `enabled` is preserved). Persists atomically. */
131
- function setToolHidden(statePath, id, hidden) {
132
- if (typeof id !== 'string' || !id) throw new Error('setToolHidden: id required');
133
- assertNotReserved(id, 'setToolHidden');
134
- const s = loadToolState(statePath);
135
- s[id] = { ...(s[id] || {}), hidden: !!hidden };
136
- atomicWriteJson(statePath, s);
137
- return s;
138
- }
139
-
140
- /** Drop a tool's overlay entry entirely (revert to defaults: active + not hidden).
141
- * No-op if the id was never toggled. Persists atomically. */
142
- function clearToolState(statePath, id) {
143
- if (typeof id !== 'string' || !id) throw new Error('clearToolState: id required');
144
- assertNotReserved(id, 'clearToolState'); // a register tool id'd "passthrough" must not clear a live wrap
145
- const s = loadToolState(statePath);
146
- delete s[id];
147
- atomicWriteJson(statePath, s);
148
- return s;
149
- }
150
-
151
- /** The active passthrough upstream id, or null. Only a non-empty STRING value counts - a
152
- * per-tool object entry that happens to be keyed "passthrough" is not a wrap. */
153
- function getPassthrough(state) {
154
- const v = state && state.passthrough;
155
- return typeof v === 'string' && v.length > 0 ? v : null;
156
- }
157
-
158
- /** Set (a non-empty string) or clear (null/'') the passthrough wrap. Persists atomically. */
159
- function setPassthrough(statePath, upstreamId) {
160
- const s = loadToolState(statePath);
161
- if (typeof upstreamId === 'string' && upstreamId.length > 0) {
162
- s.passthrough = upstreamId;
163
- } else {
164
- delete s.passthrough;
165
- }
166
- atomicWriteJson(statePath, s);
167
- return s;
168
- }
169
-
170
- module.exports = { loadToolState, loadToolStateResult, isToolEnabled, isToolHidden, isToolHot, setToolEnabled, setToolHidden, setToolHot, clearToolState, getPassthrough, setPassthrough };
1
+ 'use strict';
2
+
3
+ /**
4
+ * tool-state.js - per-tool ACTIVE/DISABLED overlay for the register.
5
+ *
6
+ * Mirrors a sibling state overlay (see a config file): a small
7
+ * overlay keyed by tool id, written atomically, loaded on startup -> survives
8
+ * close/detach (persistent session-to-session). The register (tools.register.json)
9
+ * holds ALL tools; this overlay decides which are surfaced to toolfunnel_list_tools.
10
+ *
11
+ * DEFAULT ON: a tool absent from the overlay is ACTIVE. Only an explicit
12
+ * { enabled: false } disables it. So newly-added tools are active until you
13
+ * untick them - and the file only ever lists the tools you've toggled.
14
+ *
15
+ * THREE independent axes (the visibility MATRIX), all keyed by the surfaced name
16
+ * (a local tool id, OR an upstream's surfaced name `<upstream>_<tool>` / its `as`,
17
+ * OR a meta-tool name like `toolfunnel_list_tools`):
18
+ * - enabled : LEAN-VISIBLE - surfaced by toolfunnel_list_tools AND runnable. Default ON.
19
+ * - hidden : manager-list declutter only (UI/tf_list). Default OFF. INDEPENDENT of enabled.
20
+ * - hot : promoted to the TOP-LEVEL tools/list (injected EVERY turn). Default depends on
21
+ * the tool KIND, supplied by the caller via isToolHot(state,id,defaultHot):
22
+ * meta-tools default hot:true (the management surface), local/upstream default
23
+ * hot:false (opt-in promotion). A disabled tool is never hot.
24
+ *
25
+ * Shape of tools.state.json: { "<id>": { "enabled": false, "hidden": true, "hot": true }, ... }
26
+ *
27
+ * PASSTHROUGH (0.6.0): one RESERVED top-level key, `"passthrough": "<upstreamId>"` (a STRING,
28
+ * unambiguous against the per-tool object entries). When set, the gateway becomes a transparent
29
+ * wrapper for that one upstream: its tools ARE the advertised surface (real schemas, implicitly
30
+ * hot), the meta-tools and everything else are hidden and uncallable, and every call still fires
31
+ * the PreToolUse gate. An explicit `enabled:false` on an upstream tool is still honoured (the
32
+ * safety off-switch survives the wrap). Set/cleared by `toolfunnel wrap <id>` / `wrap --off`.
33
+ *
34
+ * CommonJS, Node built-ins only. Reuses registry.js's atomicWriteJson (temp+rename).
35
+ */
36
+
37
+ const fs = require('node:fs');
38
+ const { atomicWriteJson } = require('./registry');
39
+
40
+ /** Load the overlay ({} if missing/malformed - never throws). */
41
+ function loadToolState(statePath) {
42
+ try {
43
+ const d = JSON.parse(fs.readFileSync(statePath, 'utf8'));
44
+ return (d && typeof d === 'object' && !Array.isArray(d)) ? d : {};
45
+ } catch (_e) {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Like loadToolState, but distinguishes "file MISSING" (defaults are correct) from "file PRESENT
52
+ * but unparseable / not an object" (a corrupt overlay - the caller may want to fail closed or warn
53
+ * rather than silently serve defaults). Returns { state, parseError }. `parseError` is true ONLY
54
+ * when the file exists but could not be read as a JSON object. NEVER throws.
55
+ * @param {string} statePath
56
+ * @returns {{ state: object, parseError: boolean }}
57
+ */
58
+ function loadToolStateResult(statePath) {
59
+ let raw;
60
+ try {
61
+ raw = fs.readFileSync(statePath, 'utf8');
62
+ } catch (_e) {
63
+ return { state: {}, parseError: false }; // missing (or unreadable) -> defaults, not a corruption
64
+ }
65
+ try {
66
+ const d = JSON.parse(raw);
67
+ if (d && typeof d === 'object' && !Array.isArray(d)) return { state: d, parseError: false };
68
+ return { state: {}, parseError: true }; // present but not a JSON object
69
+ } catch (_e) {
70
+ return { state: {}, parseError: true }; // present but not valid JSON
71
+ }
72
+ }
73
+
74
+ /** Default ON: enabled unless the overlay explicitly says enabled:false. */
75
+ function isToolEnabled(state, id) {
76
+ const e = state && state[id];
77
+ return !(e && e.enabled === false);
78
+ }
79
+
80
+ /** Default NOT hidden: hidden only when the overlay explicitly says hidden:true.
81
+ * `hidden` is a manager-list-only flag (declutter); it is INDEPENDENT of `enabled`
82
+ * (MCP visibility). A tool can be active-but-hidden, or disabled-but-visible. */
83
+ function isToolHidden(state, id) {
84
+ const e = state && state[id];
85
+ return !!(e && e.hidden === true);
86
+ }
87
+
88
+ /** Default OFF for local/upstream, ON for meta - the caller supplies the kind-appropriate
89
+ * `defaultHot`. An explicit boolean in the overlay always wins; absence falls back to the
90
+ * default. `hot` is the TOP-LEVEL (every-turn) promotion axis - INDEPENDENT of enabled/hidden,
91
+ * though a disabled tool is filtered out of the top-level surface by the assembler, not here. */
92
+ function isToolHot(state, id, defaultHot) {
93
+ const e = state && state[id];
94
+ if (e && typeof e.hot === 'boolean') return e.hot;
95
+ return !!defaultHot;
96
+ }
97
+
98
+ /** The one top-level key reserved for the passthrough wrap (a STRING value). A per-tool overlay
99
+ * entry may NEVER use this key, else a per-tool toggle would clobber (or be clobbered by) the wrap
100
+ * - they share the same flat object. The per-tool setters reject
101
+ * it; a tool whose surfaced name is literally "passthrough" simply cannot carry an overlay entry
102
+ * (it stays at defaults - enabled, not hot, not hidden), which is a fine, safe degradation. */
103
+ const RESERVED_PASSTHROUGH_KEY = 'passthrough';
104
+ function assertNotReserved(id, fn) {
105
+ if (id === RESERVED_PASSTHROUGH_KEY) {
106
+ throw new Error(`${fn}: "${RESERVED_PASSTHROUGH_KEY}" is a reserved state key (the passthrough wrap) and cannot be a per-tool id`);
107
+ }
108
+ }
109
+
110
+ /** Set a tool's enabled flag, MERGING (so `hidden`/`hot` are preserved). Persists atomically. */
111
+ function setToolEnabled(statePath, id, enabled) {
112
+ if (typeof id !== 'string' || !id) throw new Error('setToolEnabled: id required');
113
+ assertNotReserved(id, 'setToolEnabled');
114
+ const s = loadToolState(statePath);
115
+ s[id] = { ...(s[id] || {}), enabled: !!enabled };
116
+ atomicWriteJson(statePath, s);
117
+ return s;
118
+ }
119
+
120
+ /** Set a tool's hot flag, MERGING (so `enabled`/`hidden` are preserved). Persists atomically. */
121
+ function setToolHot(statePath, id, hot) {
122
+ if (typeof id !== 'string' || !id) throw new Error('setToolHot: id required');
123
+ assertNotReserved(id, 'setToolHot');
124
+ const s = loadToolState(statePath);
125
+ s[id] = { ...(s[id] || {}), hot: !!hot };
126
+ atomicWriteJson(statePath, s);
127
+ return s;
128
+ }
129
+
130
+ /** Set a tool's hidden flag, MERGING (so `enabled` is preserved). Persists atomically. */
131
+ function setToolHidden(statePath, id, hidden) {
132
+ if (typeof id !== 'string' || !id) throw new Error('setToolHidden: id required');
133
+ assertNotReserved(id, 'setToolHidden');
134
+ const s = loadToolState(statePath);
135
+ s[id] = { ...(s[id] || {}), hidden: !!hidden };
136
+ atomicWriteJson(statePath, s);
137
+ return s;
138
+ }
139
+
140
+ /** Drop a tool's overlay entry entirely (revert to defaults: active + not hidden).
141
+ * No-op if the id was never toggled. Persists atomically. */
142
+ function clearToolState(statePath, id) {
143
+ if (typeof id !== 'string' || !id) throw new Error('clearToolState: id required');
144
+ assertNotReserved(id, 'clearToolState'); // a register tool id'd "passthrough" must not clear a live wrap
145
+ const s = loadToolState(statePath);
146
+ delete s[id];
147
+ atomicWriteJson(statePath, s);
148
+ return s;
149
+ }
150
+
151
+ /** The active passthrough upstream id, or null. Only a non-empty STRING value counts - a
152
+ * per-tool object entry that happens to be keyed "passthrough" is not a wrap. */
153
+ function getPassthrough(state) {
154
+ const v = state && state.passthrough;
155
+ return typeof v === 'string' && v.length > 0 ? v : null;
156
+ }
157
+
158
+ /** Set (a non-empty string) or clear (null/'') the passthrough wrap. Persists atomically. */
159
+ function setPassthrough(statePath, upstreamId) {
160
+ const s = loadToolState(statePath);
161
+ if (typeof upstreamId === 'string' && upstreamId.length > 0) {
162
+ s.passthrough = upstreamId;
163
+ } else {
164
+ delete s.passthrough;
165
+ }
166
+ atomicWriteJson(statePath, s);
167
+ return s;
168
+ }
169
+
170
+ module.exports = { loadToolState, loadToolStateResult, isToolEnabled, isToolHidden, isToolHot, setToolEnabled, setToolHidden, setToolHot, clearToolState, getPassthrough, setPassthrough };