toolfunnel 0.5.0 → 0.6.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 (69) hide show
  1. package/README.md +231 -167
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -33
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  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 +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -1012
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1405
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -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 +1348 -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 +112 -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 +227 -216
@@ -1,180 +1,180 @@
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 };
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 };