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.
- package/README.md +231 -167
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -33
- 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 -1012
- 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 -1405
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -513
- 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
|
@@ -1,129 +1,134 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* tf-tool-add.js
|
|
6
|
-
*
|
|
7
|
-
* Purpose
|
|
8
|
-
* -------
|
|
9
|
-
* The write-side counterpart to the read-only status / inventory tools: it lets
|
|
10
|
-
* the manager surface a NEW tool in the register at runtime (no reconnect
|
|
11
|
-
* running gateway watches tools.register.json and hot-reloads its register on
|
|
12
|
-
* change). Optionally it also writes the host-local script the new entry invokes,
|
|
13
|
-
* so a single call can create both the register entry AND its backing script
|
|
14
|
-
* atomically.
|
|
15
|
-
*
|
|
16
|
-
* This is a "gateway-run" tool: it mutates ToolFunnel's OWN config (the register
|
|
17
|
-
* and, optionally, a script under the scripts root). It runs through the same
|
|
18
|
-
* register -> resolveExecution -> gated run path as any other script tool, so
|
|
19
|
-
* the PreToolUse gate still applies to invoking it.
|
|
20
|
-
*
|
|
21
|
-
* Contract with the register (registry.js `defaultRunScript`)
|
|
22
|
-
* ----------------------------------------------------------
|
|
23
|
-
* - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} / absent => null).
|
|
24
|
-
* - Args: { id, name, summary?, category?, instructions?, inputSchema?,
|
|
25
|
-
* invoke: { type:'script'|'shell', path?|command? },
|
|
26
|
-
* scriptText? }
|
|
27
|
-
* inputSchema (a JSON Schema object) is what a HOT-promoted tool advertises verbatim in
|
|
28
|
-
* the top-level tools/list
|
|
29
|
-
* "your own tools AND schemas".
|
|
30
|
-
* When scriptText is a string AND invoke.type === 'script', the script is
|
|
31
|
-
* written first (registry.writeScript), THEN the entry is added.
|
|
32
|
-
* - Prints a SINGLE JSON object to stdout and exits 0
|
|
33
|
-
* a logical error (reported as { ok:false, error }), never a thrown exception.
|
|
34
|
-
*
|
|
35
|
-
* Output (stdout), exactly one JSON object:
|
|
36
|
-
* success: { ok:true, ...<the added register entry>, scriptPath?: "<abs path>" }
|
|
37
|
-
* failure: { ok:false, error }
|
|
38
|
-
*
|
|
39
|
-
* Safety invariants:
|
|
40
|
-
* - Touches ONLY files inside the toolfunnel root, resolved from THIS script's
|
|
41
|
-
* own location (__dirname)
|
|
42
|
-
* register's writeScript guards path-escape (basename + inside-root check).
|
|
43
|
-
* - NO network. Writes are atomic (temp + fsync + rename) via the register module.
|
|
44
|
-
* - NEVER throws for ordinary bad input
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
const path = require('node:path');
|
|
48
|
-
// Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
|
|
49
|
-
const { HOME, srcRequire } = require('./tf-env');
|
|
50
|
-
const { loadRegistry } = srcRequire('tools/registry');
|
|
51
|
-
|
|
52
|
-
const ROOT = HOME;
|
|
53
|
-
const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
|
|
54
|
-
const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
|
|
58
|
-
* @returns {{ value: any } | { parseError: string }}
|
|
59
|
-
*/
|
|
60
|
-
function parseStructuredArgs() {
|
|
61
|
-
const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
|
|
62
|
-
if (raw === undefined || raw === null || raw === '') {
|
|
63
|
-
return { value: null };
|
|
64
|
-
}
|
|
65
|
-
try {
|
|
66
|
-
return { value: JSON.parse(raw) };
|
|
67
|
-
} catch (_err) {
|
|
68
|
-
return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Pure core: given the parsed args, add the register entry (and optionally write
|
|
74
|
-
* its backing script first). Throws are caught by main() and surfaced as
|
|
75
|
-
* { ok:false, error }; loadRegistry / writeScript / add all throw on bad shape.
|
|
76
|
-
*
|
|
77
|
-
* @param {any} args
|
|
78
|
-
* @returns {object} the JSON-serialisable result
|
|
79
|
-
*/
|
|
80
|
-
function run(args) {
|
|
81
|
-
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
82
|
-
return {
|
|
83
|
-
ok: false,
|
|
84
|
-
error: 'args must be an object { id, name, summary?, category?, instructions?, inputSchema?, invoke
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
const { id, name, summary, category, instructions, inputSchema, invoke, scriptText } = args;
|
|
88
|
-
|
|
89
|
-
// Build a clean register entry (only carry through the optional fields when
|
|
90
|
-
// present so the persisted entry stays tidy). registry.add validates shape.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* tf-tool-add.js - a first-party MANAGEMENT tool that ADDS a register entry.
|
|
6
|
+
*
|
|
7
|
+
* Purpose
|
|
8
|
+
* -------
|
|
9
|
+
* The write-side counterpart to the read-only status / inventory tools: it lets
|
|
10
|
+
* the manager surface a NEW tool in the register at runtime (no reconnect - the
|
|
11
|
+
* running gateway watches tools.register.json and hot-reloads its register on
|
|
12
|
+
* change). Optionally it also writes the host-local script the new entry invokes,
|
|
13
|
+
* so a single call can create both the register entry AND its backing script
|
|
14
|
+
* atomically.
|
|
15
|
+
*
|
|
16
|
+
* This is a "gateway-run" tool: it mutates ToolFunnel's OWN config (the register
|
|
17
|
+
* and, optionally, a script under the scripts root). It runs through the same
|
|
18
|
+
* register -> resolveExecution -> gated run path as any other script tool, so
|
|
19
|
+
* the PreToolUse gate still applies to invoking it.
|
|
20
|
+
*
|
|
21
|
+
* Contract with the register (registry.js `defaultRunScript`)
|
|
22
|
+
* ----------------------------------------------------------
|
|
23
|
+
* - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} / absent => null).
|
|
24
|
+
* - Args: { id, name, summary?, category?, instructions?, inputSchema?,
|
|
25
|
+
* invoke: { type:'script'|'shell', path?|command? },
|
|
26
|
+
* scriptText? }
|
|
27
|
+
* inputSchema (a JSON Schema object) is what a HOT-promoted tool advertises verbatim in
|
|
28
|
+
* the top-level tools/list - dropping it here was the gap between "your own tools" and
|
|
29
|
+
* "your own tools AND schemas".
|
|
30
|
+
* When scriptText is a string AND invoke.type === 'script', the script is
|
|
31
|
+
* written first (registry.writeScript), THEN the entry is added.
|
|
32
|
+
* - Prints a SINGLE JSON object to stdout and exits 0 - ALWAYS exit 0, even on
|
|
33
|
+
* a logical error (reported as { ok:false, error }), never a thrown exception.
|
|
34
|
+
*
|
|
35
|
+
* Output (stdout), exactly one JSON object:
|
|
36
|
+
* success: { ok:true, ...<the added register entry>, scriptPath?: "<abs path>" }
|
|
37
|
+
* failure: { ok:false, error }
|
|
38
|
+
*
|
|
39
|
+
* Safety invariants:
|
|
40
|
+
* - Touches ONLY files inside the toolfunnel root, resolved from THIS script's
|
|
41
|
+
* own location (__dirname) - never a caller-supplied absolute path. The
|
|
42
|
+
* register's writeScript guards path-escape (basename + inside-root check).
|
|
43
|
+
* - NO network. Writes are atomic (temp + fsync + rename) via the register module.
|
|
44
|
+
* - NEVER throws for ordinary bad input - only well-shaped JSON on stdout.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const path = require('node:path');
|
|
48
|
+
// Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
|
|
49
|
+
const { HOME, srcRequire } = require('./tf-env');
|
|
50
|
+
const { loadRegistry } = srcRequire('tools/registry');
|
|
51
|
+
|
|
52
|
+
const ROOT = HOME;
|
|
53
|
+
const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
|
|
54
|
+
const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
|
|
58
|
+
* @returns {{ value: any } | { parseError: string }}
|
|
59
|
+
*/
|
|
60
|
+
function parseStructuredArgs() {
|
|
61
|
+
const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
|
|
62
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
63
|
+
return { value: null };
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return { value: JSON.parse(raw) };
|
|
67
|
+
} catch (_err) {
|
|
68
|
+
return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pure core: given the parsed args, add the register entry (and optionally write
|
|
74
|
+
* its backing script first). Throws are caught by main() and surfaced as
|
|
75
|
+
* { ok:false, error }; loadRegistry / writeScript / add all throw on bad shape.
|
|
76
|
+
*
|
|
77
|
+
* @param {any} args
|
|
78
|
+
* @returns {object} the JSON-serialisable result
|
|
79
|
+
*/
|
|
80
|
+
function run(args) {
|
|
81
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: 'args must be an object { id, name, summary?, category?, instructions?, inputSchema?, mode?, invoke?, scriptText? }',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const { id, name, summary, category, instructions, inputSchema, mode, invoke, scriptText } = args;
|
|
88
|
+
|
|
89
|
+
// Build a clean register entry (only carry through the optional fields when
|
|
90
|
+
// present so the persisted entry stays tidy). registry.add validates shape.
|
|
91
|
+
// `mode` MUST ride along: a reference-mode tool has NO invoke at all, and dropping the field
|
|
92
|
+
// here made the agent path reject every reference tool with "entry.invoke is required" while
|
|
93
|
+
// the UI path accepted them.
|
|
94
|
+
const entry = { id, name };
|
|
95
|
+
if (invoke !== undefined) entry.invoke = invoke;
|
|
96
|
+
if (mode !== undefined) entry.mode = mode;
|
|
97
|
+
if (summary !== undefined) entry.summary = summary;
|
|
98
|
+
if (category !== undefined) entry.category = category;
|
|
99
|
+
if (instructions !== undefined) entry.instructions = instructions;
|
|
100
|
+
if (inputSchema !== undefined) entry.inputSchema = inputSchema; // validated by registry.add
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
104
|
+
|
|
105
|
+
// When a script body is supplied for a script invoke, write it FIRST so the
|
|
106
|
+
// entry never references a missing file once add() succeeds.
|
|
107
|
+
let scriptPath;
|
|
108
|
+
if (typeof scriptText === 'string' && invoke && invoke.type === 'script') {
|
|
109
|
+
scriptPath = registry.writeScript(invoke.path, scriptText);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const added = registry.add(entry);
|
|
113
|
+
const result = { ok: true, ...added };
|
|
114
|
+
if (scriptPath) result.scriptPath = scriptPath;
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function main() {
|
|
119
|
+
const parsed = parseStructuredArgs();
|
|
120
|
+
let payload;
|
|
121
|
+
if (parsed.parseError) {
|
|
122
|
+
payload = { ok: false, error: parsed.parseError };
|
|
123
|
+
} else {
|
|
124
|
+
try {
|
|
125
|
+
payload = run(parsed.value);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
payload = { ok: false, error: (err && err.message) || String(err) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
main();
|
|
@@ -1,142 +1,142 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* tf-tool-set.js
|
|
6
|
-
*
|
|
7
|
-
* Purpose
|
|
8
|
-
* -------
|
|
9
|
-
* The state-side counterpart to tf-tool-add: it flips a tool's ACTIVE/DISABLED
|
|
10
|
-
* overlay flag (enable / disable) or fully removes a tool (drops the register
|
|
11
|
-
* entry AND clears its state overlay). Register/state edits are visible to the
|
|
12
|
-
* meta-tools on the next call
|
|
13
|
-
*
|
|
14
|
-
* This is a "gateway-run" tool: it mutates ToolFunnel's OWN config (the per-tool
|
|
15
|
-
* state overlay, and for `remove`, the register too). It runs through the same
|
|
16
|
-
* gated run path as any other script tool.
|
|
17
|
-
*
|
|
18
|
-
* Contract with the register (registry.js `defaultRunScript`)
|
|
19
|
-
* ----------------------------------------------------------
|
|
20
|
-
* - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} / absent => null).
|
|
21
|
-
* - Args: { id, action: 'enable' | 'disable' | 'hot' | 'unhot' | 'hide' | 'unhide' | 'remove' }.
|
|
22
|
-
* enable/disable -> setToolEnabled(state, id, bool) (overlay merge; preserves hidden/hot)
|
|
23
|
-
* hot/unhot -> setToolHot(state, id, bool) (overlay merge; preserves enabled/hidden)
|
|
24
|
-
*
|
|
25
|
-
* hide/unhide -> setToolHidden(state, id, bool) (overlay merge; preserves enabled/hot)
|
|
26
|
-
*
|
|
27
|
-
* what the connected AI sees (the lean list / top-level surface).
|
|
28
|
-
* remove -> registry.remove(id) AND clearToolState(state, id)
|
|
29
|
-
* - Prints a SINGLE JSON object to stdout and exits 0
|
|
30
|
-
* a logical error (reported as { ok:false, error }), never a thrown exception.
|
|
31
|
-
*
|
|
32
|
-
* Output (stdout), exactly one JSON object:
|
|
33
|
-
* enable/disable: { ok:true, id, action, enabled, state }
|
|
34
|
-
* hot/unhot: { ok:true, id, action, hot, state }
|
|
35
|
-
* hide/unhide: { ok:true, id, action, hidden, state }
|
|
36
|
-
* remove: { ok:true, id, action, removed:true, state }
|
|
37
|
-
* failure: { ok:false, error }
|
|
38
|
-
* (`state` is the resulting overlay map after the operation.)
|
|
39
|
-
*
|
|
40
|
-
* Safety invariants:
|
|
41
|
-
* - Touches ONLY config files inside the toolfunnel root, resolved from THIS
|
|
42
|
-
* script's own location (__dirname)
|
|
43
|
-
* - NO network. Writes are atomic (temp + fsync + rename) via the store modules.
|
|
44
|
-
* - NEVER throws for ordinary bad input
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
const path = require('node:path');
|
|
48
|
-
// Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
|
|
49
|
-
const { HOME, srcRequire } = require('./tf-env');
|
|
50
|
-
const { loadRegistry } = srcRequire('tools/registry');
|
|
51
|
-
const { setToolEnabled, setToolHot, setToolHidden, clearToolState } = srcRequire('tools/tool-state');
|
|
52
|
-
|
|
53
|
-
const ROOT = HOME;
|
|
54
|
-
const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
|
|
55
|
-
const STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
|
|
56
|
-
const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
|
|
60
|
-
* @returns {{ value: any } | { parseError: string }}
|
|
61
|
-
*/
|
|
62
|
-
function parseStructuredArgs() {
|
|
63
|
-
const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
|
|
64
|
-
if (raw === undefined || raw === null || raw === '') {
|
|
65
|
-
return { value: null };
|
|
66
|
-
}
|
|
67
|
-
try {
|
|
68
|
-
return { value: JSON.parse(raw) };
|
|
69
|
-
} catch (_err) {
|
|
70
|
-
return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Pure core: given the parsed args, perform the requested state/register change.
|
|
76
|
-
* Throws are caught by main() and surfaced as { ok:false, error }; registry.remove
|
|
77
|
-
* throws on an unknown id, setToolEnabled/clearToolState throw on a bad id.
|
|
78
|
-
*
|
|
79
|
-
* @param {any} args
|
|
80
|
-
* @returns {object} the JSON-serialisable result
|
|
81
|
-
*/
|
|
82
|
-
function run(args) {
|
|
83
|
-
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
84
|
-
return { ok: false, error: "args must be an object { id, action: 'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove' }" };
|
|
85
|
-
}
|
|
86
|
-
const { id, action } = args;
|
|
87
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
88
|
-
return { ok: false, error: 'id must be a non-empty string' };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (action === 'enable' || action === 'disable') {
|
|
92
|
-
const enabled = action === 'enable';
|
|
93
|
-
const state = setToolEnabled(STATE_PATH, id, enabled);
|
|
94
|
-
return { ok: true, id, action, enabled, state };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (action === 'hot' || action === 'unhot') {
|
|
98
|
-
// Promote (hot) / demote (unhot) the tool on the TOP-LEVEL every-turn surface. Keyed by the
|
|
99
|
-
// surfaced name (a local id, an upstream surfaced name, or a meta-tool name)
|
|
100
|
-
// server's matrix assembler reads. The id is NOT verified against the register here: an upstream
|
|
101
|
-
// surfaced name or a meta-tool name is a legitimate key with no register entry.
|
|
102
|
-
const hot = action === 'hot';
|
|
103
|
-
const state = setToolHot(STATE_PATH, id, hot);
|
|
104
|
-
return { ok: true, id, action, hot, state };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (action === 'hide' || action === 'unhide') {
|
|
108
|
-
// Declutter the MANAGER views (tf_list + the UI) only
|
|
109
|
-
// or the top-level surface the connected AI sees. Same id-not-verified policy as hot/unhot.
|
|
110
|
-
const hidden = action === 'hide';
|
|
111
|
-
const state = setToolHidden(STATE_PATH, id, hidden);
|
|
112
|
-
return { ok: true, id, action, hidden, state };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (action === 'remove') {
|
|
116
|
-
// Drop the register entry first (throws on unknown id), then clear any overlay.
|
|
117
|
-
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
118
|
-
registry.remove(id);
|
|
119
|
-
const state = clearToolState(STATE_PATH, id);
|
|
120
|
-
return { ok: true, id, action, removed: true, state };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return { ok: false, error: "action must be one of 'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove'" };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function main() {
|
|
127
|
-
const parsed = parseStructuredArgs();
|
|
128
|
-
let payload;
|
|
129
|
-
if (parsed.parseError) {
|
|
130
|
-
payload = { ok: false, error: parsed.parseError };
|
|
131
|
-
} else {
|
|
132
|
-
try {
|
|
133
|
-
payload = run(parsed.value);
|
|
134
|
-
} catch (err) {
|
|
135
|
-
payload = { ok: false, error: (err && err.message) || String(err) };
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
139
|
-
process.exit(0);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
main();
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* tf-tool-set.js - a first-party MANAGEMENT tool that toggles or removes a tool.
|
|
6
|
+
*
|
|
7
|
+
* Purpose
|
|
8
|
+
* -------
|
|
9
|
+
* The state-side counterpart to tf-tool-add: it flips a tool's ACTIVE/DISABLED
|
|
10
|
+
* overlay flag (enable / disable) or fully removes a tool (drops the register
|
|
11
|
+
* entry AND clears its state overlay). Register/state edits are visible to the
|
|
12
|
+
* meta-tools on the next call - no reconnect.
|
|
13
|
+
*
|
|
14
|
+
* This is a "gateway-run" tool: it mutates ToolFunnel's OWN config (the per-tool
|
|
15
|
+
* state overlay, and for `remove`, the register too). It runs through the same
|
|
16
|
+
* gated run path as any other script tool.
|
|
17
|
+
*
|
|
18
|
+
* Contract with the register (registry.js `defaultRunScript`)
|
|
19
|
+
* ----------------------------------------------------------
|
|
20
|
+
* - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} / absent => null).
|
|
21
|
+
* - Args: { id, action: 'enable' | 'disable' | 'hot' | 'unhot' | 'hide' | 'unhide' | 'remove' }.
|
|
22
|
+
* enable/disable -> setToolEnabled(state, id, bool) (overlay merge; preserves hidden/hot)
|
|
23
|
+
* hot/unhot -> setToolHot(state, id, bool) (overlay merge; preserves enabled/hidden)
|
|
24
|
+
* - promote/demote the tool on the TOP-LEVEL every-turn surface (the matrix).
|
|
25
|
+
* hide/unhide -> setToolHidden(state, id, bool) (overlay merge; preserves enabled/hot)
|
|
26
|
+
* - declutter the MANAGER views only (tf_list + the UI); does NOT change
|
|
27
|
+
* what the connected AI sees (the lean list / top-level surface).
|
|
28
|
+
* remove -> registry.remove(id) AND clearToolState(state, id)
|
|
29
|
+
* - Prints a SINGLE JSON object to stdout and exits 0 - ALWAYS exit 0, even on
|
|
30
|
+
* a logical error (reported as { ok:false, error }), never a thrown exception.
|
|
31
|
+
*
|
|
32
|
+
* Output (stdout), exactly one JSON object:
|
|
33
|
+
* enable/disable: { ok:true, id, action, enabled, state }
|
|
34
|
+
* hot/unhot: { ok:true, id, action, hot, state }
|
|
35
|
+
* hide/unhide: { ok:true, id, action, hidden, state }
|
|
36
|
+
* remove: { ok:true, id, action, removed:true, state }
|
|
37
|
+
* failure: { ok:false, error }
|
|
38
|
+
* (`state` is the resulting overlay map after the operation.)
|
|
39
|
+
*
|
|
40
|
+
* Safety invariants:
|
|
41
|
+
* - Touches ONLY config files inside the toolfunnel root, resolved from THIS
|
|
42
|
+
* script's own location (__dirname) - never a caller-supplied path.
|
|
43
|
+
* - NO network. Writes are atomic (temp + fsync + rename) via the store modules.
|
|
44
|
+
* - NEVER throws for ordinary bad input - only well-shaped JSON on stdout.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const path = require('node:path');
|
|
48
|
+
// Shared HOME/engine resolution (see tf-env.js): config beside us, engine from the package.
|
|
49
|
+
const { HOME, srcRequire } = require('./tf-env');
|
|
50
|
+
const { loadRegistry } = srcRequire('tools/registry');
|
|
51
|
+
const { setToolEnabled, setToolHot, setToolHidden, clearToolState } = srcRequire('tools/tool-state');
|
|
52
|
+
|
|
53
|
+
const ROOT = HOME;
|
|
54
|
+
const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
|
|
55
|
+
const STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
|
|
56
|
+
const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
|
|
60
|
+
* @returns {{ value: any } | { parseError: string }}
|
|
61
|
+
*/
|
|
62
|
+
function parseStructuredArgs() {
|
|
63
|
+
const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
|
|
64
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
65
|
+
return { value: null };
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return { value: JSON.parse(raw) };
|
|
69
|
+
} catch (_err) {
|
|
70
|
+
return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pure core: given the parsed args, perform the requested state/register change.
|
|
76
|
+
* Throws are caught by main() and surfaced as { ok:false, error }; registry.remove
|
|
77
|
+
* throws on an unknown id, setToolEnabled/clearToolState throw on a bad id.
|
|
78
|
+
*
|
|
79
|
+
* @param {any} args
|
|
80
|
+
* @returns {object} the JSON-serialisable result
|
|
81
|
+
*/
|
|
82
|
+
function run(args) {
|
|
83
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
84
|
+
return { ok: false, error: "args must be an object { id, action: 'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove' }" };
|
|
85
|
+
}
|
|
86
|
+
const { id, action } = args;
|
|
87
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
88
|
+
return { ok: false, error: 'id must be a non-empty string' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (action === 'enable' || action === 'disable') {
|
|
92
|
+
const enabled = action === 'enable';
|
|
93
|
+
const state = setToolEnabled(STATE_PATH, id, enabled);
|
|
94
|
+
return { ok: true, id, action, enabled, state };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (action === 'hot' || action === 'unhot') {
|
|
98
|
+
// Promote (hot) / demote (unhot) the tool on the TOP-LEVEL every-turn surface. Keyed by the
|
|
99
|
+
// surfaced name (a local id, an upstream surfaced name, or a meta-tool name) - the same key the
|
|
100
|
+
// server's matrix assembler reads. The id is NOT verified against the register here: an upstream
|
|
101
|
+
// surfaced name or a meta-tool name is a legitimate key with no register entry.
|
|
102
|
+
const hot = action === 'hot';
|
|
103
|
+
const state = setToolHot(STATE_PATH, id, hot);
|
|
104
|
+
return { ok: true, id, action, hot, state };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (action === 'hide' || action === 'unhide') {
|
|
108
|
+
// Declutter the MANAGER views (tf_list + the UI) only - hide/unhide does NOT change the lean list
|
|
109
|
+
// or the top-level surface the connected AI sees. Same id-not-verified policy as hot/unhot.
|
|
110
|
+
const hidden = action === 'hide';
|
|
111
|
+
const state = setToolHidden(STATE_PATH, id, hidden);
|
|
112
|
+
return { ok: true, id, action, hidden, state };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (action === 'remove') {
|
|
116
|
+
// Drop the register entry first (throws on unknown id), then clear any overlay.
|
|
117
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
118
|
+
registry.remove(id);
|
|
119
|
+
const state = clearToolState(STATE_PATH, id);
|
|
120
|
+
return { ok: true, id, action, removed: true, state };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { ok: false, error: "action must be one of 'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove'" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function main() {
|
|
127
|
+
const parsed = parseStructuredArgs();
|
|
128
|
+
let payload;
|
|
129
|
+
if (parsed.parseError) {
|
|
130
|
+
payload = { ok: false, error: parsed.parseError };
|
|
131
|
+
} else {
|
|
132
|
+
try {
|
|
133
|
+
payload = run(parsed.value);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
payload = { ok: false, error: (err && err.message) || String(err) };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
main();
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* tf-wrap.js - gateway-run MANAGEMENT tool: set, clear, or report the PASSTHROUGH wrap.
|
|
6
|
+
*
|
|
7
|
+
* Wrapping turns the gateway into a transparent wrapper for ONE attached upstream MCP: its
|
|
8
|
+
* tools become the ENTIRE advertised surface (under their ORIGINAL names), the meta-tools and
|
|
9
|
+
* every other tool are hidden and uncallable, and every call still fires the PreToolUse gate.
|
|
10
|
+
* Because the gateway answers both MCP eras, this is how a legacy-only (or unmaintained) MCP
|
|
11
|
+
* keeps working with 2026-07-28-era clients.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ SELF-LOCKOUT: the wrap hides ALL ToolFunnel tools - INCLUDING tf_wrap itself. The AI that
|
|
14
|
+
* sets a wrap loses in-band access to undo it. That is why `confirm: true` is REQUIRED to set
|
|
15
|
+
* one. Undo paths that survive the wrap: the config web UI (--ui), or `toolfunnel wrap --off`
|
|
16
|
+
* on a terminal. A running host picks either up live.
|
|
17
|
+
*
|
|
18
|
+
* Contract with the register (`defaultRunScript`)
|
|
19
|
+
* ----------------------------------------------------------
|
|
20
|
+
* - Structured args arrive as a JSON string in env TOOLFUNNEL_TOOL_ARGS ({} if absent).
|
|
21
|
+
* Shape: {} (status) | { off:true } (clear) | { upstream:'<id>', confirm:true } (set).
|
|
22
|
+
* - Prints EXACTLY ONE JSON line to stdout and exits 0 - ALWAYS. A logical failure is
|
|
23
|
+
* reported as { ok:false, error }.
|
|
24
|
+
*
|
|
25
|
+
* Output (stdout), exactly one JSON object:
|
|
26
|
+
* status: { ok:true, wrapping:(id|null), upstreams:[id...] }
|
|
27
|
+
* clear: { ok:true, cleared:true, was:(id|null) }
|
|
28
|
+
* set: { ok:true, wrapping:id, warning:'...' }
|
|
29
|
+
* failure:{ ok:false, error }
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
const { HOME, srcRequire } = require('./tf-env');
|
|
34
|
+
const { loadExposeStore } = srcRequire('mcp/expose-store');
|
|
35
|
+
const { loadToolState, getPassthrough, setPassthrough } = srcRequire('tools/tool-state');
|
|
36
|
+
|
|
37
|
+
const STATE_PATH = path.join(HOME, 'tools', 'tools.state.json');
|
|
38
|
+
const EXPOSE_PATH = path.join(HOME, 'mcp', 'expose.json');
|
|
39
|
+
|
|
40
|
+
const LOCKOUT_WARNING =
|
|
41
|
+
'The wrap hides ALL ToolFunnel tools - including tf_wrap itself. In-band undo is now ' +
|
|
42
|
+
'impossible for you: recover via the config web UI (--ui) or `toolfunnel wrap --off` on a terminal.';
|
|
43
|
+
|
|
44
|
+
function parseArgs() {
|
|
45
|
+
const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
|
|
46
|
+
if (raw === undefined || raw === null || raw === '') return {};
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
49
|
+
throw new Error('TOOLFUNNEL_TOOL_ARGS must be a JSON object');
|
|
50
|
+
}
|
|
51
|
+
return parsed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function run() {
|
|
55
|
+
const args = parseArgs();
|
|
56
|
+
|
|
57
|
+
if (args.off === true) {
|
|
58
|
+
const was = getPassthrough(loadToolState(STATE_PATH));
|
|
59
|
+
setPassthrough(STATE_PATH, null);
|
|
60
|
+
return { ok: true, cleared: true, was };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
64
|
+
const ids = store.listUpstreams().map((u) => u.id);
|
|
65
|
+
|
|
66
|
+
if (args.upstream === undefined) {
|
|
67
|
+
return { ok: true, wrapping: getPassthrough(loadToolState(STATE_PATH)), upstreams: ids };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const id = args.upstream;
|
|
71
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
72
|
+
throw new Error('args.upstream must be a non-empty string (an attached upstream id)');
|
|
73
|
+
}
|
|
74
|
+
const upstream = store.getUpstream(id);
|
|
75
|
+
if (!upstream) {
|
|
76
|
+
throw new Error(`no upstream with id "${id}" - attached: ${ids.join(', ') || '(none)'}`);
|
|
77
|
+
}
|
|
78
|
+
if (upstream.enabled === false) {
|
|
79
|
+
throw new Error(`upstream "${id}" is disabled in mcp/expose.json - enable it first (tf_mcp_set)`);
|
|
80
|
+
}
|
|
81
|
+
if (args.confirm !== true) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'confirm:true is required. ' + LOCKOUT_WARNING +
|
|
84
|
+
' Call again with { "upstream": "' + id + '", "confirm": true } to proceed.'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setPassthrough(STATE_PATH, id);
|
|
89
|
+
return { ok: true, wrapping: id, warning: LOCKOUT_WARNING };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function main() {
|
|
93
|
+
let payload;
|
|
94
|
+
try {
|
|
95
|
+
payload = run();
|
|
96
|
+
} catch (err) {
|
|
97
|
+
payload = { ok: false, error: (err && err.message) || String(err) };
|
|
98
|
+
}
|
|
99
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
main();
|