toolfunnel 0.5.0 → 0.7.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 (72) hide show
  1. package/README.md +235 -167
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -147
  4. package/docs/KNOWN_BUGS.md +80 -33
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -0
  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 -0
  13. package/mcp/servers/mock-upstream/server.js +222 -12
  14. package/package.json +67 -65
  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 -77
  30. package/src/extend/add-hook.md +152 -142
  31. package/src/extend/add-mcp.md +119 -121
  32. package/src/extend/configure.md +88 -0
  33. package/src/extend/create-tool.md +168 -152
  34. package/src/extend/howto.js +102 -98
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -0
  37. package/src/mcp/aggregator.js +1674 -807
  38. package/src/mcp/expose-store.js +577 -430
  39. package/src/mcp/gated-run.js +210 -194
  40. package/src/mcp/http-transport.js +1563 -1012
  41. package/src/mcp/mcp-client.js +1070 -530
  42. package/src/mcp/modern.js +406 -0
  43. package/src/mcp/protocol.js +468 -466
  44. package/src/mcp/server.js +3112 -1405
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1187 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1190
  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 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +14 -3
  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,102 +1,102 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * uuid-tool.js — a safe, dependency-free local UUID generator.
6
- *
7
- * Purpose
8
- * -------
9
- * Generate one or more RFC 4122 v4 UUIDs via the Node built-in
10
- * `node:crypto.randomUUID()`. Useful from the Tool Manager whenever a fresh
11
- * identifier is needed. 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: { count?: <integer> } — how many UUIDs to mint. Defaults to 1.
17
- * count is clamped to the range [1, 100] so a runaway request cannot ask
18
- * for an unbounded number.
19
- * - Prints a SINGLE JSON object to stdout and exits 0 — ALWAYS exit 0, even
20
- * on bad input (reported as { ok:false, error }), never a thrown exception.
21
- *
22
- * Output (stdout), exactly one JSON object:
23
- * success: { ok:true, uuids: [ "...", ... ] }
24
- * failure: { ok:false, error }
25
- *
26
- * Safety invariants (mirror the isolation rule):
27
- * - NO filesystem, NO network, NO process mutation. Pure CPU on the args.
28
- * - count is hard-capped at 100; a non-integer / out-of-range count is an
29
- * error rather than a silent surprise.
30
- * - NEVER throws for ordinary bad input — only well-shaped JSON on stdout.
31
- */
32
-
33
- const crypto = require('node:crypto');
34
-
35
- const MAX_COUNT = 100;
36
-
37
- /**
38
- * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
39
- * @returns {{ value: any } | { parseError: string }}
40
- */
41
- function parseStructuredArgs() {
42
- const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
43
- if (raw === undefined || raw === null || raw === '') {
44
- return { value: null };
45
- }
46
- try {
47
- return { value: JSON.parse(raw) };
48
- } catch (_err) {
49
- return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
50
- }
51
- }
52
-
53
- /**
54
- * Pure core: given the parsed args, produce the result object. Never throws.
55
- *
56
- * @param {any} args may be null (treated as { count: 1 })
57
- * @returns {object} the JSON-serialisable result
58
- */
59
- function run(args) {
60
- // No args (or null) is valid — default to a single UUID.
61
- let count = 1;
62
- if (args !== null && args !== undefined) {
63
- if (typeof args !== 'object' || Array.isArray(args)) {
64
- return { ok: false, error: 'args must be an object { count? }' };
65
- }
66
- if (args.count !== undefined && args.count !== null) {
67
- if (typeof args.count !== 'number' || !Number.isInteger(args.count)) {
68
- return { ok: false, error: 'count must be an integer' };
69
- }
70
- if (args.count < 1) {
71
- return { ok: false, error: 'count must be >= 1' };
72
- }
73
- if (args.count > MAX_COUNT) {
74
- return { ok: false, error: `count must be <= ${MAX_COUNT}` };
75
- }
76
- count = args.count;
77
- }
78
- }
79
- const uuids = [];
80
- for (let i = 0; i < count; i += 1) {
81
- uuids.push(crypto.randomUUID());
82
- }
83
- return { ok: true, uuids };
84
- }
85
-
86
- function main() {
87
- const parsed = parseStructuredArgs();
88
- let payload;
89
- if (parsed.parseError) {
90
- payload = { ok: false, error: parsed.parseError };
91
- } else {
92
- try {
93
- payload = run(parsed.value);
94
- } catch (err) {
95
- payload = { ok: false, error: `unexpected error: ${(err && err.message) || String(err)}` };
96
- }
97
- }
98
- process.stdout.write(JSON.stringify(payload) + '\n');
99
- process.exit(0);
100
- }
101
-
102
- main();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * uuid-tool.js - a safe, dependency-free local UUID generator.
6
+ *
7
+ * Purpose
8
+ * -------
9
+ * Generate one or more RFC 4122 v4 UUIDs via the Node built-in
10
+ * `node:crypto.randomUUID()`. Useful from the Tool Manager whenever a fresh
11
+ * identifier is needed. 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: { count?: <integer> } - how many UUIDs to mint. Defaults to 1.
17
+ * count is clamped to the range [1, 100] so a runaway request cannot ask
18
+ * for an unbounded number.
19
+ * - Prints a SINGLE JSON object to stdout and exits 0 - ALWAYS exit 0, even
20
+ * on bad input (reported as { ok:false, error }), never a thrown exception.
21
+ *
22
+ * Output (stdout), exactly one JSON object:
23
+ * success: { ok:true, uuids: [ "...", ... ] }
24
+ * failure: { ok:false, error }
25
+ *
26
+ * Safety invariants (mirror the isolation rule):
27
+ * - NO filesystem, NO network, NO process mutation. Pure CPU on the args.
28
+ * - count is hard-capped at 100; a non-integer / out-of-range count is an
29
+ * error rather than a silent surprise.
30
+ * - NEVER throws for ordinary bad input - only well-shaped JSON on stdout.
31
+ */
32
+
33
+ const crypto = require('node:crypto');
34
+
35
+ const MAX_COUNT = 100;
36
+
37
+ /**
38
+ * Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
39
+ * @returns {{ value: any } | { parseError: string }}
40
+ */
41
+ function parseStructuredArgs() {
42
+ const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
43
+ if (raw === undefined || raw === null || raw === '') {
44
+ return { value: null };
45
+ }
46
+ try {
47
+ return { value: JSON.parse(raw) };
48
+ } catch (_err) {
49
+ return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Pure core: given the parsed args, produce the result object. Never throws.
55
+ *
56
+ * @param {any} args may be null (treated as { count: 1 })
57
+ * @returns {object} the JSON-serialisable result
58
+ */
59
+ function run(args) {
60
+ // No args (or null) is valid - default to a single UUID.
61
+ let count = 1;
62
+ if (args !== null && args !== undefined) {
63
+ if (typeof args !== 'object' || Array.isArray(args)) {
64
+ return { ok: false, error: 'args must be an object { count? }' };
65
+ }
66
+ if (args.count !== undefined && args.count !== null) {
67
+ if (typeof args.count !== 'number' || !Number.isInteger(args.count)) {
68
+ return { ok: false, error: 'count must be an integer' };
69
+ }
70
+ if (args.count < 1) {
71
+ return { ok: false, error: 'count must be >= 1' };
72
+ }
73
+ if (args.count > MAX_COUNT) {
74
+ return { ok: false, error: `count must be <= ${MAX_COUNT}` };
75
+ }
76
+ count = args.count;
77
+ }
78
+ }
79
+ const uuids = [];
80
+ for (let i = 0; i < count; i += 1) {
81
+ uuids.push(crypto.randomUUID());
82
+ }
83
+ return { ok: true, uuids };
84
+ }
85
+
86
+ function main() {
87
+ const parsed = parseStructuredArgs();
88
+ let payload;
89
+ if (parsed.parseError) {
90
+ payload = { ok: false, error: parsed.parseError };
91
+ } else {
92
+ try {
93
+ payload = run(parsed.value);
94
+ } catch (err) {
95
+ payload = { ok: false, error: `unexpected error: ${(err && err.message) || String(err)}` };
96
+ }
97
+ }
98
+ process.stdout.write(JSON.stringify(payload) + '\n');
99
+ process.exit(0);
100
+ }
101
+
102
+ main();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "description": "Tool register for the gateway — built-in demo scripts.",
3
+ "description": "Tool register for the gateway - built-in demo scripts.",
4
4
  "tools": [
5
5
  {
6
6
  "id": "echo",
@@ -95,7 +95,7 @@
95
95
  "name": "tf_tool_set",
96
96
  "summary": "Activate, deactivate, promote (hot), hide, or remove a register tool.",
97
97
  "category": "management",
98
- "instructions": "Args: { id, action:'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove' }. enable/disable flips the active overlay (default ON) — disabled tools drop from toolfunnel_list_tools. hot/unhot promote/demote on the TOP-LEVEL every-turn surface (tools/list), making it directly callable (default OFF). hide/unhide declutter the MANAGER views only (tf_list + UI) and do NOT change what the AI sees. The id may be a local tool id, an upstream surfaced name, or a meta-tool name. remove deletes the register entry and purges its state key. All LIVE (no restart).",
98
+ "instructions": "Args: { id, action:'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove' }. enable/disable flips the active overlay (default ON) - disabled tools drop from toolfunnel_list_tools. hot/unhot promote/demote on the TOP-LEVEL every-turn surface (tools/list), making it directly callable (default OFF). hide/unhide declutter the MANAGER views only (tf_list + UI) and do NOT change what the AI sees. The id may be a local tool id, an upstream surfaced name, or a meta-tool name. remove deletes the register entry and purges its state key. All LIVE (no restart).",
99
99
  "invoke": {
100
100
  "type": "script",
101
101
  "path": "scripts/tf-tool-set.js"
@@ -123,6 +123,17 @@
123
123
  "path": "scripts/tf-mcp-set.js"
124
124
  }
125
125
  },
126
+ {
127
+ "id": "tf_wrap",
128
+ "name": "tf_wrap",
129
+ "summary": "Wrap ONE attached upstream MCP: its tools become the entire surface (transparent passthrough).",
130
+ "category": "management",
131
+ "instructions": "Args: {} reports status ({ wrapping, upstreams }). { off:true } clears the wrap. { upstream:'<id>', confirm:true } sets it: that upstream's tools become the ENTIRE advertised surface under their ORIGINAL names, everything else (meta-tools INCLUDED) is hidden and uncallable, and every call still fires the PreToolUse gate. Because the gateway answers both MCP eras, this keeps a legacy-only MCP working with 2026-07-28-era clients. WARNING (why confirm is required): the wrap hides tf_wrap itself, so YOU cannot undo it in-band afterwards - recovery is the config web UI (--ui) or `toolfunnel wrap --off` on a terminal. A running host applies the change live.",
132
+ "invoke": {
133
+ "type": "script",
134
+ "path": "scripts/tf-wrap.js"
135
+ }
136
+ },
126
137
  {
127
138
  "id": "tf_hook_add",
128
139
  "name": "tf_hook_add",
@@ -150,7 +161,7 @@
150
161
  "name": "tf_list",
151
162
  "summary": "List register tools, upstream MCPs, or hooks with their active state.",
152
163
  "category": "management",
153
- "instructions": "Args: { kind:'tools'|'mcps'|'hooks' }. tools → register briefs + enabled/hidden overlay; mcps → upstreams + expose selections; hooks → configured + auto-detected inventory with live enabled state. Read-only.",
164
+ "instructions": "Args: { kind:'tools'|'mcps'|'hooks' }. tools -> register briefs + enabled/hidden overlay; mcps -> upstreams + expose selections; hooks -> configured + auto-detected inventory with live enabled state. Read-only.",
154
165
  "invoke": {
155
166
  "type": "script",
156
167
  "path": "scripts/tf-list.js"
@@ -1,180 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * tool-registry.js — backend-agnostic tool catalogue for the host loop.
5
- *
6
- * The host asks the registry for tool definitions to hand to a backend
7
- * (see ARCHITECTURE.md: `backend.send({ system, messages, tools })`). When the
8
- * model requests a tool by name, the host executes it here. The registry holds
9
- * NO host imports so it runs headless under `node --test`.
10
- *
11
- * A tool is registered as: register(name, { description, inputSchema, run })
12
- * - description: human/model-facing string
13
- * - inputSchema: a JSON-schema-ish object describing the input (handed to the backend)
14
- * - run(input): the implementation; may be sync or async; its return value is the result
15
- */
16
-
17
- const fs = require('node:fs');
18
- const path = require('node:path');
19
-
20
- class ToolRegistry {
21
- constructor() {
22
- // name -> { description, inputSchema, run }
23
- this._tools = new Map();
24
- }
25
-
26
- /**
27
- * Register a tool under a unique name.
28
- * Throws on a bad shape so misconfiguration is caught at wiring time, not at
29
- * model-call time (the loop must never receive a half-formed tool).
30
- *
31
- * @param {string} name
32
- * @param {{ description?: string, inputSchema?: object, run: (input:any)=>any }} def
33
- * @returns {ToolRegistry} this (for chaining)
34
- */
35
- register(name, def) {
36
- if (typeof name !== 'string' || name.length === 0) {
37
- throw new Error('ToolRegistry.register: name must be a non-empty string');
38
- }
39
- if (!def || typeof def !== 'object') {
40
- throw new Error(`ToolRegistry.register("${name}"): definition object is required`);
41
- }
42
- if (typeof def.run !== 'function') {
43
- throw new Error(`ToolRegistry.register("${name}"): "run" must be a function`);
44
- }
45
- if (this._tools.has(name)) {
46
- throw new Error(`ToolRegistry.register("${name}"): tool already registered`);
47
- }
48
- this._tools.set(name, {
49
- description: typeof def.description === 'string' ? def.description : '',
50
- // Default to an open object schema so a tool without a declared schema
51
- // still produces a valid definition for the backend.
52
- inputSchema:
53
- def.inputSchema && typeof def.inputSchema === 'object'
54
- ? def.inputSchema
55
- : { type: 'object', properties: {} },
56
- run: def.run,
57
- });
58
- return this;
59
- }
60
-
61
- /** @param {string} name @returns {boolean} */
62
- has(name) {
63
- return this._tools.has(name);
64
- }
65
-
66
- /**
67
- * Execute a registered tool by name. Always returns a Promise so the host
68
- * loop can `await` uniformly regardless of whether `run` was sync or async.
69
- * Throws a clear error if the tool is unknown (the host turns this into a
70
- * tool result / PostToolUse feedback rather than crashing the loop).
71
- *
72
- * @param {string} name
73
- * @param {any} input
74
- * @returns {Promise<any>} the tool's result
75
- */
76
- async execute(name, input) {
77
- const tool = this._tools.get(name);
78
- if (!tool) {
79
- const known = this.list().map((t) => t.name);
80
- throw new Error(
81
- `ToolRegistry.execute: unknown tool "${name}". ` +
82
- `Registered tools: ${known.length ? known.join(', ') : '(none)'}`
83
- );
84
- }
85
- // Await covers both sync return values and returned Promises.
86
- return await tool.run(input);
87
- }
88
-
89
- /**
90
- * Tool definitions array, shaped for handing to a backend:
91
- * `[{ name, description, inputSchema }]`. The `run` function is deliberately
92
- * omitted — the backend never needs (or should see) the implementation.
93
- *
94
- * @returns {Array<{ name: string, description: string, inputSchema: object }>}
95
- */
96
- list() {
97
- const out = [];
98
- for (const [name, tool] of this._tools) {
99
- out.push({
100
- name,
101
- description: tool.description,
102
- inputSchema: tool.inputSchema,
103
- });
104
- }
105
- return out;
106
- }
107
- }
108
-
109
- /**
110
- * makeDemoRegistry — a ToolRegistry preloaded with two demo tools used by the
111
- * host tests (host.test.js drives the lifecycle through these).
112
- *
113
- * echo — returns its input unchanged (trivial, deterministic).
114
- * readFileSandboxed — reads a UTF-8 file, but ONLY within `sandboxRoot`,
115
- * rejecting any path that escapes the sandbox. This is
116
- * the same defense-in-depth posture the hook loader's
117
- * writeScript guard uses (HOOK_ENGINE.md §8) — a tool the
118
- * model can call must not become a filesystem escape.
119
- *
120
- * @param {object} [opts]
121
- * @param {string} [opts.sandboxRoot=process.cwd()] absolute root for readFileSandboxed
122
- * @returns {ToolRegistry}
123
- */
124
- function makeDemoRegistry(opts) {
125
- const sandboxRoot = path.resolve((opts && opts.sandboxRoot) || process.cwd());
126
- const registry = new ToolRegistry();
127
-
128
- registry.register('echo', {
129
- description: 'Returns its input unchanged. Useful for testing the tool loop.',
130
- inputSchema: {
131
- type: 'object',
132
- description: 'Any JSON value passed through as-is.',
133
- properties: {},
134
- additionalProperties: true,
135
- },
136
- run: (input) => input,
137
- });
138
-
139
- registry.register('readFileSandboxed', {
140
- description:
141
- 'Reads a UTF-8 text file located within the sandbox root. ' +
142
- 'Rejects absolute paths and any relative path that escapes the sandbox.',
143
- inputSchema: {
144
- type: 'object',
145
- properties: {
146
- path: {
147
- type: 'string',
148
- description: 'Path to read, relative to the sandbox root.',
149
- },
150
- },
151
- required: ['path'],
152
- },
153
- run: (input) => {
154
- const requested = input && input.path;
155
- if (typeof requested !== 'string' || requested.length === 0) {
156
- throw new Error('readFileSandboxed: "path" (non-empty string) is required');
157
- }
158
- // Reject absolute paths outright; everything must be relative to the sandbox.
159
- if (path.isAbsolute(requested)) {
160
- throw new Error(
161
- `readFileSandboxed: absolute paths are not allowed ("${requested}")`
162
- );
163
- }
164
- // Resolve against the sandbox, then confirm the result is still inside it.
165
- const resolved = path.resolve(sandboxRoot, requested);
166
- const rel = path.relative(sandboxRoot, resolved);
167
- // rel starting with ".." (or an absolute path on a different drive) means escape.
168
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
169
- throw new Error(
170
- `readFileSandboxed: path escapes sandbox root ("${requested}")`
171
- );
172
- }
173
- return fs.readFileSync(resolved, 'utf8');
174
- },
175
- });
176
-
177
- return registry;
178
- }
179
-
180
- module.exports = { ToolRegistry, makeDemoRegistry };