toolfunnel 0.4.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.
- package/README.md +102 -213
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -225
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +70 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +31 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +192 -12
- package/package.json +1 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +344 -344
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +275 -275
- package/src/core/hook-loader.js +791 -791
- package/src/core/hook-runner.js +346 -346
- package/src/core/logger.js +171 -171
- package/src/core/matcher.js +51 -51
- package/src/core/metrics.js +75 -75
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/core/tool-registry.js +180 -180
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +122 -121
- package/src/extend/configure.md +87 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +187 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1246 -807
- package/src/mcp/expose-store.js +460 -430
- package/src/mcp/gated-run.js +201 -194
- package/src/mcp/http-transport.js +1311 -981
- package/src/mcp/mcp-client.js +907 -530
- package/src/mcp/modern.js +372 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +2688 -1322
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -511
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1174 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1348 -1190
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +112 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -216
- package/docs/admin-ui.md +0 -404
- package/docs/configuration.md +0 -402
- package/docs/hooks-and-gating.md +0 -428
- package/docs/logging.md +0 -223
- package/docs/management.md +0 -429
- package/docs/oauth.md +0 -103
- package/docs/releasing.md +0 -31
- package/docs/setup.md +0 -352
- package/docs/tools-and-upstreams.md +0 -469
|
@@ -1,180 +1,180 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* tool-registry.js
|
|
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
|
|
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
|
|
111
|
-
* host tests (host.test.js drives the lifecycle through these).
|
|
112
|
-
*
|
|
113
|
-
* echo
|
|
114
|
-
* readFileSandboxed
|
|
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)
|
|
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 };
|