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,131 +1,131 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * text-stats.js - a safe, dependency-free local text-statistics tool.
6
- *
7
- * Purpose
8
- * -------
9
- * Count characters, words, lines, and UTF-8 bytes for a string. A handy
10
- * Tool-Manager utility for sizing prompts, snippets, and payloads. Pure
11
- * string work - no network, no filesystem, no new deps.
12
- *
13
- * Contract with the register (the register's `defaultRunScript`)
14
- * ----------------------------------------------------------
15
- * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS.
16
- * - Args: { text: <string> }.
17
- * - Prints a SINGLE JSON object to stdout and exits 0 - ALWAYS exit 0, even
18
- * on bad input (reported as { ok:false, error }), never a thrown exception.
19
- *
20
- * Output (stdout), exactly one JSON object:
21
- * success: { ok:true, chars, words, lines, bytes }
22
- * failure: { ok:false, error }
23
- *
24
- * Counting conventions (documented so callers know exactly what they get):
25
- * - chars : JS string length (UTF-16 code units - the same number `"".length`
26
- * reports). Documented as such; for ASCII text it equals the visible
27
- * character count.
28
- * - words : maximal runs of non-whitespace separated by whitespace. The empty
29
- * / all-whitespace string has 0 words.
30
- * - lines : number of lines. The empty string is 0 lines; otherwise it is the
31
- * count of newline-separated segments (a trailing newline does NOT
32
- * add a phantom empty final line - `"a\n"` is 1 line, `"a\nb"` is 2).
33
- * - bytes : UTF-8 byte length (Buffer.byteLength), the on-the-wire size.
34
- *
35
- * Safety invariants (mirror the isolation rule):
36
- * - NO filesystem, NO network, NO process mutation. Pure CPU on the args.
37
- * - NEVER throws for ordinary bad input - only well-shaped JSON on stdout.
38
- */
39
-
40
- /**
41
- * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
42
- * @returns {{ value: any } | { parseError: string }}
43
- */
44
- function parseStructuredArgs() {
45
- const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
46
- if (raw === undefined || raw === null || raw === '') {
47
- return { value: null };
48
- }
49
- try {
50
- return { value: JSON.parse(raw) };
51
- } catch (_err) {
52
- return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
53
- }
54
- }
55
-
56
- /**
57
- * Count lines per the documented convention: 0 for the empty string, else the
58
- * number of newline-separated segments WITHOUT counting a phantom trailing
59
- * empty line. We normalise CRLF/CR to LF first so line counts match what a user
60
- * sees regardless of the platform that produced the text.
61
- *
62
- * @param {string} text
63
- * @returns {number}
64
- */
65
- function countLines(text) {
66
- if (text.length === 0) return 0;
67
- const normalised = text.replace(/\r\n?/g, '\n');
68
- // Trailing newline should not add an empty line: trim a single trailing '\n'
69
- // before splitting, then add 1 for the final (now-unterminated) line.
70
- const trimmed = normalised.endsWith('\n') ? normalised.slice(0, -1) : normalised;
71
- // Each '\n' in `trimmed` separates two lines -> lines = (#newlines) + 1.
72
- let newlines = 0;
73
- for (let i = 0; i < trimmed.length; i += 1) {
74
- if (trimmed.charCodeAt(i) === 10) newlines += 1;
75
- }
76
- return newlines + 1;
77
- }
78
-
79
- /**
80
- * Count words: maximal non-whitespace runs. Empty / whitespace-only -> 0.
81
- *
82
- * @param {string} text
83
- * @returns {number}
84
- */
85
- function countWords(text) {
86
- const trimmed = text.trim();
87
- if (trimmed.length === 0) return 0;
88
- // Split on any whitespace run; the trim above guarantees no empty edge tokens.
89
- return trimmed.split(/\s+/).length;
90
- }
91
-
92
- /**
93
- * Pure core: given the parsed args, produce the result object. Never throws.
94
- *
95
- * @param {any} args
96
- * @returns {object} the JSON-serialisable result
97
- */
98
- function run(args) {
99
- if (!args || typeof args !== 'object' || Array.isArray(args)) {
100
- return { ok: false, error: 'args must be an object { text }' };
101
- }
102
- const { text } = args;
103
- if (typeof text !== 'string') {
104
- return { ok: false, error: 'text must be a string' };
105
- }
106
- return {
107
- ok: true,
108
- chars: text.length,
109
- words: countWords(text),
110
- lines: countLines(text),
111
- bytes: Buffer.byteLength(text, 'utf8'),
112
- };
113
- }
114
-
115
- function main() {
116
- const parsed = parseStructuredArgs();
117
- let payload;
118
- if (parsed.parseError) {
119
- payload = { ok: false, error: parsed.parseError };
120
- } else {
121
- try {
122
- payload = run(parsed.value);
123
- } catch (err) {
124
- payload = { ok: false, error: `unexpected error: ${(err && err.message) || String(err)}` };
125
- }
126
- }
127
- process.stdout.write(JSON.stringify(payload) + '\n');
128
- process.exit(0);
129
- }
130
-
131
- main();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * text-stats.js - a safe, dependency-free local text-statistics tool.
6
+ *
7
+ * Purpose
8
+ * -------
9
+ * Count characters, words, lines, and UTF-8 bytes for a string. A handy
10
+ * Tool-Manager utility for sizing prompts, snippets, and payloads. Pure
11
+ * string work - no network, no filesystem, no new deps.
12
+ *
13
+ * Contract with the register (the register's `defaultRunScript`)
14
+ * ----------------------------------------------------------
15
+ * - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS.
16
+ * - Args: { text: <string> }.
17
+ * - Prints a SINGLE JSON object to stdout and exits 0 - ALWAYS exit 0, even
18
+ * on bad input (reported as { ok:false, error }), never a thrown exception.
19
+ *
20
+ * Output (stdout), exactly one JSON object:
21
+ * success: { ok:true, chars, words, lines, bytes }
22
+ * failure: { ok:false, error }
23
+ *
24
+ * Counting conventions (documented so callers know exactly what they get):
25
+ * - chars : JS string length (UTF-16 code units - the same number `"".length`
26
+ * reports). Documented as such; for ASCII text it equals the visible
27
+ * character count.
28
+ * - words : maximal runs of non-whitespace separated by whitespace. The empty
29
+ * / all-whitespace string has 0 words.
30
+ * - lines : number of lines. The empty string is 0 lines; otherwise it is the
31
+ * count of newline-separated segments (a trailing newline does NOT
32
+ * add a phantom empty final line - `"a\n"` is 1 line, `"a\nb"` is 2).
33
+ * - bytes : UTF-8 byte length (Buffer.byteLength), the on-the-wire size.
34
+ *
35
+ * Safety invariants (mirror the isolation rule):
36
+ * - NO filesystem, NO network, NO process mutation. Pure CPU on the args.
37
+ * - NEVER throws for ordinary bad input - only well-shaped JSON on stdout.
38
+ */
39
+
40
+ /**
41
+ * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
42
+ * @returns {{ value: any } | { parseError: string }}
43
+ */
44
+ function parseStructuredArgs() {
45
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
46
+ if (raw === undefined || raw === null || raw === '') {
47
+ return { value: null };
48
+ }
49
+ try {
50
+ return { value: JSON.parse(raw) };
51
+ } catch (_err) {
52
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Count lines per the documented convention: 0 for the empty string, else the
58
+ * number of newline-separated segments WITHOUT counting a phantom trailing
59
+ * empty line. We normalise CRLF/CR to LF first so line counts match what a user
60
+ * sees regardless of the platform that produced the text.
61
+ *
62
+ * @param {string} text
63
+ * @returns {number}
64
+ */
65
+ function countLines(text) {
66
+ if (text.length === 0) return 0;
67
+ const normalised = text.replace(/\r\n?/g, '\n');
68
+ // Trailing newline should not add an empty line: trim a single trailing '\n'
69
+ // before splitting, then add 1 for the final (now-unterminated) line.
70
+ const trimmed = normalised.endsWith('\n') ? normalised.slice(0, -1) : normalised;
71
+ // Each '\n' in `trimmed` separates two lines -> lines = (#newlines) + 1.
72
+ let newlines = 0;
73
+ for (let i = 0; i < trimmed.length; i += 1) {
74
+ if (trimmed.charCodeAt(i) === 10) newlines += 1;
75
+ }
76
+ return newlines + 1;
77
+ }
78
+
79
+ /**
80
+ * Count words: maximal non-whitespace runs. Empty / whitespace-only -> 0.
81
+ *
82
+ * @param {string} text
83
+ * @returns {number}
84
+ */
85
+ function countWords(text) {
86
+ const trimmed = text.trim();
87
+ if (trimmed.length === 0) return 0;
88
+ // Split on any whitespace run; the trim above guarantees no empty edge tokens.
89
+ return trimmed.split(/\s+/).length;
90
+ }
91
+
92
+ /**
93
+ * Pure core: given the parsed args, produce the result object. Never throws.
94
+ *
95
+ * @param {any} args
96
+ * @returns {object} the JSON-serialisable result
97
+ */
98
+ function run(args) {
99
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
100
+ return { ok: false, error: 'args must be an object { text }' };
101
+ }
102
+ const { text } = args;
103
+ if (typeof text !== 'string') {
104
+ return { ok: false, error: 'text must be a string' };
105
+ }
106
+ return {
107
+ ok: true,
108
+ chars: text.length,
109
+ words: countWords(text),
110
+ lines: countLines(text),
111
+ bytes: Buffer.byteLength(text, 'utf8'),
112
+ };
113
+ }
114
+
115
+ function main() {
116
+ const parsed = parseStructuredArgs();
117
+ let payload;
118
+ if (parsed.parseError) {
119
+ payload = { ok: false, error: parsed.parseError };
120
+ } else {
121
+ try {
122
+ payload = run(parsed.value);
123
+ } catch (err) {
124
+ payload = { ok: false, error: `unexpected error: ${(err && err.message) || String(err)}` };
125
+ }
126
+ }
127
+ process.stdout.write(JSON.stringify(payload) + '\n');
128
+ process.exit(0);
129
+ }
130
+
131
+ main();
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tf-accept-change.js - the tf_accept_change management tool (tool descriptor approval).
4
+ *
5
+ * When an upstream's tool changes its description or schema while the server version stays the
6
+ * same, the gateway keeps serving the APPROVED descriptor and refuses calls to the tool, returning
7
+ * which fields changed and this tool as the way to accept (the new text itself is shown only in
8
+ * `toolfunnel changes` and the UI). The agent is meant to ASK THE USER
9
+ * first; the call is gated by PreToolUse like every register tool, and so is the in-chat accept a
10
+ * 2026-07-28 client gets (the gateway gates it as tf_accept_change).
11
+ *
12
+ * Contract (same as the other tf-* scripts):
13
+ * - Args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS: { upstream, tool, hash }, all required:
14
+ * an agent accepts exactly the change the user was shown - one tool, at the hash from the
15
+ * refusal (a server that changed again is not approved sight unseen). Accepting several
16
+ * at once is for the user: `toolfunnel accept <upstream>` or the UI.
17
+ * - Output: ONE JSON line { ok, accepted, pending, error? }; exit 0 either way.
18
+ * - The logic lives in the engine (tool-approvals.js acceptInFile), shared with the CLI, the UI
19
+ * and the gateway; this script only adapts it. The running gateway applies the accept on the
20
+ * next call (it re-reads the file) and its file watcher refreshes the listings.
21
+ */
22
+
23
+ const path = require('node:path');
24
+ // Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
25
+ const { HOME, srcRequire } = require('./tf-env');
26
+ const approvals = srcRequire('mcp/tool-approvals');
27
+
28
+ const APPROVALS_PATH = path.join(HOME, 'mcp', 'tool-approvals.json');
29
+
30
+ function parseArgs() {
31
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
32
+ if (raw === undefined || raw === null || raw === '') return {};
33
+ const parsed = JSON.parse(raw);
34
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
35
+ throw new Error('TOOLFUNNEL_TOOL_ARGS must be a JSON object');
36
+ }
37
+ return parsed;
38
+ }
39
+
40
+ function run() {
41
+ const a = parseArgs();
42
+ if (typeof a.upstream !== 'string' || a.upstream.length === 0) throw new Error('"upstream" (the upstream id) is required');
43
+ if (typeof a.tool !== 'string' || a.tool.length === 0) throw new Error('"tool" (the changed tool\'s name, from the refusal) is required');
44
+ if (typeof a.hash !== 'string' || a.hash.length === 0) throw new Error('"hash" (from the refusal) is required');
45
+ const res = approvals.acceptInFile(APPROVALS_PATH, a.upstream, a.tool, a.hash);
46
+ const why = approvals.acceptFailure(res, a.upstream, a.tool);
47
+ if (why) {
48
+ return {
49
+ ok: false, accepted: [], pending: res.pending || [],
50
+ error: why + (res.stale ? '. Call the tool once more to see the current change, and ask the user again.' : ''),
51
+ };
52
+ }
53
+ return { ok: true, accepted: res.accepted, pending: res.pending, note: 'The running gateway applies this from the next call.' };
54
+ }
55
+
56
+ function main() {
57
+ let payload;
58
+ try {
59
+ payload = run();
60
+ } catch (err) {
61
+ payload = { ok: false, accepted: [], pending: [], error: (err && err.message) || String(err) };
62
+ }
63
+ process.stdout.write(JSON.stringify(payload) + '\n');
64
+ process.exit(0);
65
+ }
66
+
67
+ main();
@@ -1,43 +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 };
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 };