toolfunnel 0.3.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/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/toolfunnel.js +112 -0
- package/docs/admin-ui.md +404 -0
- package/docs/configuration.md +402 -0
- package/docs/hooks-and-gating.md +428 -0
- package/docs/logging.md +223 -0
- package/docs/management.md +429 -0
- package/docs/oauth.md +103 -0
- package/docs/setup.md +352 -0
- package/docs/tools-and-upstreams.md +469 -0
- package/hooks/hooks.manifest.json +27 -0
- package/hooks/scripts/.gitkeep +3 -0
- package/hooks/scripts/example-audit.js +21 -0
- package/hooks/scripts/example-deny-dangerous.js +31 -0
- package/mcp/expose.example.json +30 -0
- package/mcp/servers/mock-upstream/server.js +170 -0
- package/package.json +63 -0
- package/src/auth/config.js +172 -0
- package/src/auth/install.js +181 -0
- package/src/auth/resource-server.js +319 -0
- package/src/core/events.js +105 -0
- package/src/core/hook-engine.js +275 -0
- package/src/core/hook-loader.js +791 -0
- package/src/core/hook-runner.js +346 -0
- package/src/core/logger.js +169 -0
- package/src/core/matcher.js +51 -0
- package/src/core/metrics.js +75 -0
- package/src/core/tool-registry.js +180 -0
- package/src/extend/add-hook.md +142 -0
- package/src/extend/add-mcp.md +121 -0
- package/src/extend/create-tool.md +132 -0
- package/src/extend/howto.js +98 -0
- package/src/extend/package.md +154 -0
- package/src/mcp/aggregator.js +802 -0
- package/src/mcp/expose-store.js +430 -0
- package/src/mcp/gated-run.js +194 -0
- package/src/mcp/http-transport.js +962 -0
- package/src/mcp/mcp-client.js +524 -0
- package/src/mcp/protocol.js +466 -0
- package/src/mcp/server.js +1240 -0
- package/src/packages/packager.js +713 -0
- package/src/tools/drift.js +138 -0
- package/src/tools/registry.js +468 -0
- package/src/tools/tool-state.js +104 -0
- package/src/ui/public/app.js +1055 -0
- package/src/ui/public/index.html +249 -0
- package/src/ui/public/logo.jpg +0 -0
- package/src/ui/public/styles.css +535 -0
- package/src/ui/server.js +1144 -0
- package/tools/scripts/base64.js +139 -0
- package/tools/scripts/danger.js +61 -0
- package/tools/scripts/echo.js +39 -0
- package/tools/scripts/hash.js +101 -0
- package/tools/scripts/json.js +109 -0
- package/tools/scripts/text-stats.js +131 -0
- package/tools/scripts/tf-hook-add.js +152 -0
- package/tools/scripts/tf-hook-set.js +113 -0
- package/tools/scripts/tf-list.js +195 -0
- package/tools/scripts/tf-log.js +83 -0
- package/tools/scripts/tf-mcp-add.js +119 -0
- package/tools/scripts/tf-mcp-set.js +104 -0
- package/tools/scripts/tf-tool-add.js +129 -0
- package/tools/scripts/tf-tool-set.js +148 -0
- package/tools/scripts/uuid.js +102 -0
- package/tools/tools.register.json +171 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* tf-mcp-set.js — gateway-run MANAGEMENT tool: enable, disable, or remove an
|
|
6
|
+
* upstream MCP in the ExposeStore.
|
|
7
|
+
*
|
|
8
|
+
* A first-party "management" script-tool (discovered via list_tools, executed via
|
|
9
|
+
* run_tool through the PreToolUse gate). It edits ToolFunnel's own MCP config
|
|
10
|
+
* (mcp/expose.json) via the ExposeStore — it does not add MCP-protocol commands.
|
|
11
|
+
*
|
|
12
|
+
* Contract with the register (`defaultRunScript`)
|
|
13
|
+
* ----------------------------------------------------------
|
|
14
|
+
* - Structured args arrive as a JSON string in env TOOLFUNNEL_TOOL_ARGS ({} if
|
|
15
|
+
* absent). Shape: { id, action: 'enable'|'disable'|'remove' }.
|
|
16
|
+
* - Prints EXACTLY ONE JSON line to stdout and exits 0 — ALWAYS. A logical
|
|
17
|
+
* failure (bad args, unknown id) is reported as { ok:false, error }.
|
|
18
|
+
*
|
|
19
|
+
* Output (stdout), exactly one JSON object:
|
|
20
|
+
* enable/disable: { ok:true, action, id, upstream:{...} } // the updated entry
|
|
21
|
+
* remove: { ok:true, action, id, removed:true } // cascades expose[]
|
|
22
|
+
* failure: { ok:false, error }
|
|
23
|
+
*
|
|
24
|
+
* Behaviour
|
|
25
|
+
* - enable -> setUpstreamEnabled(id, true)
|
|
26
|
+
* - disable -> setUpstreamEnabled(id, false)
|
|
27
|
+
* - remove -> removeUpstream(id) (the store cascade-removes its expose entries)
|
|
28
|
+
* All three throw on an unknown id; the throw is reported as { ok:false, error }.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
const { loadExposeStore } = require('../../src/mcp/expose-store');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* ToolFunnel root, resolved from THIS file's location:
|
|
36
|
+
* <root>/tools/scripts/tf-mcp-set.js -> root is two dirs up.
|
|
37
|
+
*/
|
|
38
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
39
|
+
|
|
40
|
+
/** The MCP config (upstreams + expose), per the host path contract. */
|
|
41
|
+
const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse TOOLFUNNEL_TOOL_ARGS (a JSON string; {} if absent). Throws on malformed
|
|
45
|
+
* JSON or a non-object payload so the caller reports { ok:false, error }.
|
|
46
|
+
* @returns {object}
|
|
47
|
+
*/
|
|
48
|
+
function parseArgs() {
|
|
49
|
+
const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
|
|
50
|
+
if (raw === undefined || raw === null || raw === '') return {};
|
|
51
|
+
const parsed = JSON.parse(raw);
|
|
52
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
53
|
+
throw new Error('TOOLFUNNEL_TOOL_ARGS must be a JSON object');
|
|
54
|
+
}
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Perform the action. May throw — main() turns any throw into { ok:false, error }.
|
|
60
|
+
* @returns {object} the success payload
|
|
61
|
+
*/
|
|
62
|
+
function run() {
|
|
63
|
+
const args = parseArgs();
|
|
64
|
+
const id = args.id;
|
|
65
|
+
const action = args.action;
|
|
66
|
+
|
|
67
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
68
|
+
throw new Error('args.id must be a non-empty string');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
72
|
+
|
|
73
|
+
switch (action) {
|
|
74
|
+
case 'enable': {
|
|
75
|
+
const upstream = store.setUpstreamEnabled(id, true);
|
|
76
|
+
return { ok: true, action, id, upstream };
|
|
77
|
+
}
|
|
78
|
+
case 'disable': {
|
|
79
|
+
const upstream = store.setUpstreamEnabled(id, false);
|
|
80
|
+
return { ok: true, action, id, upstream };
|
|
81
|
+
}
|
|
82
|
+
case 'remove': {
|
|
83
|
+
store.removeUpstream(id);
|
|
84
|
+
return { ok: true, action, id, removed: true };
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
throw new Error(
|
|
88
|
+
`args.action must be one of enable|disable|remove (got ${JSON.stringify(action)})`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function main() {
|
|
94
|
+
let payload;
|
|
95
|
+
try {
|
|
96
|
+
payload = run();
|
|
97
|
+
} catch (err) {
|
|
98
|
+
payload = { ok: false, error: (err && err.message) || String(err) };
|
|
99
|
+
}
|
|
100
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main();
|
|
@@ -0,0 +1,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
|
+
* meta-tools re-read the register every call). Optionally it also writes the
|
|
12
|
+
* host-local script the new entry invokes, so a single call can create both the
|
|
13
|
+
* register entry AND its backing script atomically.
|
|
14
|
+
*
|
|
15
|
+
* This is a "gateway-run" tool: it mutates ToolFunnel's OWN config (the register
|
|
16
|
+
* and, optionally, a script under the scripts root). It runs through the same
|
|
17
|
+
* register -> resolveExecution -> gated run path as any other script tool, so
|
|
18
|
+
* the PreToolUse gate still applies to invoking it.
|
|
19
|
+
*
|
|
20
|
+
* Contract with the register (registry.js `defaultRunScript`)
|
|
21
|
+
* ----------------------------------------------------------
|
|
22
|
+
* - Structured args arrive as JSON in env TOOLFUNNEL_TOOL_ARGS ({} / absent => null).
|
|
23
|
+
* - Args: { id, name, summary?, category?, instructions?,
|
|
24
|
+
* invoke: { type:'script'|'shell', path?|command? },
|
|
25
|
+
* scriptText? }
|
|
26
|
+
* When scriptText is a string AND invoke.type === 'script', the script is
|
|
27
|
+
* written first (registry.writeScript), THEN the entry is added.
|
|
28
|
+
* - Prints a SINGLE JSON object to stdout and exits 0 — ALWAYS exit 0, even on
|
|
29
|
+
* a logical error (reported as { ok:false, error }), never a thrown exception.
|
|
30
|
+
*
|
|
31
|
+
* Output (stdout), exactly one JSON object:
|
|
32
|
+
* success: { ok:true, ...<the added register entry>, scriptPath?: "<abs path>" }
|
|
33
|
+
* failure: { ok:false, error }
|
|
34
|
+
*
|
|
35
|
+
* Safety invariants:
|
|
36
|
+
* - Touches ONLY files inside the toolfunnel root, resolved from THIS script's
|
|
37
|
+
* own location (__dirname) — never a caller-supplied absolute path. The
|
|
38
|
+
* register's writeScript guards path-escape (basename + inside-root check).
|
|
39
|
+
* - NO network. Writes are atomic (temp + fsync + rename) via the register module.
|
|
40
|
+
* - NEVER throws for ordinary bad input — only well-shaped JSON on stdout.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const path = require('node:path');
|
|
44
|
+
const { loadRegistry } = require('../../src/tools/registry');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The toolfunnel root, resolved from THIS file's location so the tool is portable
|
|
48
|
+
* and stays inside the sandbox.
|
|
49
|
+
*
|
|
50
|
+
* This script lives at: <root>/tools/scripts/tf-tool-add.js
|
|
51
|
+
* so the root is two directories up from __dirname:
|
|
52
|
+
* scripts -> tools -> <root>
|
|
53
|
+
*/
|
|
54
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
55
|
+
const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.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, add the register entry (and optionally write
|
|
76
|
+
* its backing script first). Throws are caught by main() and surfaced as
|
|
77
|
+
* { ok:false, error }; loadRegistry / writeScript / add all throw on bad shape.
|
|
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 {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: 'args must be an object { id, name, summary?, category?, instructions?, invoke, scriptText? }',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const { id, name, summary, category, instructions, invoke, scriptText } = args;
|
|
90
|
+
|
|
91
|
+
// Build a clean register entry (only carry through the optional fields when
|
|
92
|
+
// present so the persisted entry stays tidy). registry.add validates shape.
|
|
93
|
+
const entry = { id, name, invoke };
|
|
94
|
+
if (summary !== undefined) entry.summary = summary;
|
|
95
|
+
if (category !== undefined) entry.category = category;
|
|
96
|
+
if (instructions !== undefined) entry.instructions = instructions;
|
|
97
|
+
|
|
98
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
99
|
+
|
|
100
|
+
// When a script body is supplied for a script invoke, write it FIRST so the
|
|
101
|
+
// entry never references a missing file once add() succeeds.
|
|
102
|
+
let scriptPath;
|
|
103
|
+
if (typeof scriptText === 'string' && invoke && invoke.type === 'script') {
|
|
104
|
+
scriptPath = registry.writeScript(invoke.path, scriptText);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const added = registry.add(entry);
|
|
108
|
+
const result = { ok: true, ...added };
|
|
109
|
+
if (scriptPath) result.scriptPath = scriptPath;
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function main() {
|
|
114
|
+
const parsed = parseStructuredArgs();
|
|
115
|
+
let payload;
|
|
116
|
+
if (parsed.parseError) {
|
|
117
|
+
payload = { ok: false, error: parsed.parseError };
|
|
118
|
+
} else {
|
|
119
|
+
try {
|
|
120
|
+
payload = run(parsed.value);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
payload = { ok: false, error: (err && err.message) || String(err) };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
main();
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
const { loadRegistry } = require('../../src/tools/registry');
|
|
49
|
+
const { setToolEnabled, setToolHot, setToolHidden, clearToolState } = require('../../src/tools/tool-state');
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The toolfunnel root, resolved from THIS file's location so the tool is portable
|
|
53
|
+
* and stays inside the sandbox.
|
|
54
|
+
*
|
|
55
|
+
* This script lives at: <root>/tools/scripts/tf-tool-set.js
|
|
56
|
+
* so the root is two directories up from __dirname:
|
|
57
|
+
* scripts -> tools -> <root>
|
|
58
|
+
*/
|
|
59
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
60
|
+
const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
|
|
61
|
+
const STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
|
|
62
|
+
const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS.
|
|
66
|
+
* @returns {{ value: any } | { parseError: string }}
|
|
67
|
+
*/
|
|
68
|
+
function parseStructuredArgs() {
|
|
69
|
+
const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
|
|
70
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
71
|
+
return { value: null };
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
return { value: JSON.parse(raw) };
|
|
75
|
+
} catch (_err) {
|
|
76
|
+
return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pure core: given the parsed args, perform the requested state/register change.
|
|
82
|
+
* Throws are caught by main() and surfaced as { ok:false, error }; registry.remove
|
|
83
|
+
* throws on an unknown id, setToolEnabled/clearToolState throw on a bad id.
|
|
84
|
+
*
|
|
85
|
+
* @param {any} args
|
|
86
|
+
* @returns {object} the JSON-serialisable result
|
|
87
|
+
*/
|
|
88
|
+
function run(args) {
|
|
89
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
90
|
+
return { ok: false, error: "args must be an object { id, action: 'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove' }" };
|
|
91
|
+
}
|
|
92
|
+
const { id, action } = args;
|
|
93
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
94
|
+
return { ok: false, error: 'id must be a non-empty string' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (action === 'enable' || action === 'disable') {
|
|
98
|
+
const enabled = action === 'enable';
|
|
99
|
+
const state = setToolEnabled(STATE_PATH, id, enabled);
|
|
100
|
+
return { ok: true, id, action, enabled, state };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (action === 'hot' || action === 'unhot') {
|
|
104
|
+
// Promote (hot) / demote (unhot) the tool on the TOP-LEVEL every-turn surface. Keyed by the
|
|
105
|
+
// surfaced name (a local id, an upstream surfaced name, or a meta-tool name) — the same key the
|
|
106
|
+
// server's matrix assembler reads. The id is NOT verified against the register here: an upstream
|
|
107
|
+
// surfaced name or a meta-tool name is a legitimate key with no register entry.
|
|
108
|
+
const hot = action === 'hot';
|
|
109
|
+
const state = setToolHot(STATE_PATH, id, hot);
|
|
110
|
+
return { ok: true, id, action, hot, state };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (action === 'hide' || action === 'unhide') {
|
|
114
|
+
// Declutter the MANAGER views (tf_list + the UI) only — hide/unhide does NOT change the lean list
|
|
115
|
+
// or the top-level surface the connected AI sees. Same id-not-verified policy as hot/unhot.
|
|
116
|
+
const hidden = action === 'hide';
|
|
117
|
+
const state = setToolHidden(STATE_PATH, id, hidden);
|
|
118
|
+
return { ok: true, id, action, hidden, state };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (action === 'remove') {
|
|
122
|
+
// Drop the register entry first (throws on unknown id), then clear any overlay.
|
|
123
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
124
|
+
registry.remove(id);
|
|
125
|
+
const state = clearToolState(STATE_PATH, id);
|
|
126
|
+
return { ok: true, id, action, removed: true, state };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { ok: false, error: "action must be one of 'enable'|'disable'|'hot'|'unhot'|'hide'|'unhide'|'remove'" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function main() {
|
|
133
|
+
const parsed = parseStructuredArgs();
|
|
134
|
+
let payload;
|
|
135
|
+
if (parsed.parseError) {
|
|
136
|
+
payload = { ok: false, error: parsed.parseError };
|
|
137
|
+
} else {
|
|
138
|
+
try {
|
|
139
|
+
payload = run(parsed.value);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
payload = { ok: false, error: (err && err.message) || String(err) };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
main();
|
|
@@ -0,0 +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();
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"description": "Tool register for the gateway — built-in demo scripts.",
|
|
4
|
+
"tools": [
|
|
5
|
+
{
|
|
6
|
+
"id": "echo",
|
|
7
|
+
"name": "Echo",
|
|
8
|
+
"summary": "Return the provided arguments back unchanged.",
|
|
9
|
+
"category": "demo",
|
|
10
|
+
"instructions": "Echoes its structured args back in the result. Pass any JSON value as args and the same value is returned. Useful as a connectivity / round-trip smoke test.",
|
|
11
|
+
"invoke": {
|
|
12
|
+
"type": "script",
|
|
13
|
+
"path": "scripts/echo.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "base64",
|
|
18
|
+
"name": "Base64 Encode/Decode",
|
|
19
|
+
"summary": "Encode text to Base64 or decode Base64 back to text.",
|
|
20
|
+
"category": "encoding",
|
|
21
|
+
"instructions": "Args: { op: \"encode\"|\"decode\", text: string }. Both args required. encode -> Base64 of the UTF-8 text; decode -> UTF-8 from valid Base64 (malformed input is reported, not mangled).",
|
|
22
|
+
"invoke": {
|
|
23
|
+
"type": "script",
|
|
24
|
+
"path": "scripts/base64.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "hash",
|
|
29
|
+
"name": "Hash",
|
|
30
|
+
"summary": "Compute a cryptographic digest (e.g. sha256) of input text.",
|
|
31
|
+
"category": "crypto",
|
|
32
|
+
"instructions": "Args: { algo?: \"sha256\"|\"sha1\"|\"md5\" (default \"sha256\"), text: string }. Returns the lowercase hex digest of the UTF-8 text.",
|
|
33
|
+
"invoke": {
|
|
34
|
+
"type": "script",
|
|
35
|
+
"path": "scripts/hash.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "uuid",
|
|
40
|
+
"name": "UUID Generator",
|
|
41
|
+
"summary": "Generate a random RFC 4122 v4 UUID.",
|
|
42
|
+
"category": "generators",
|
|
43
|
+
"instructions": "Args: { count?: number (default 1) }. Returns one or more freshly generated v4 UUID strings.",
|
|
44
|
+
"invoke": {
|
|
45
|
+
"type": "script",
|
|
46
|
+
"path": "scripts/uuid.js"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "json",
|
|
51
|
+
"name": "JSON Format",
|
|
52
|
+
"summary": "Validate and pretty-print a JSON string.",
|
|
53
|
+
"category": "data",
|
|
54
|
+
"instructions": "Args: { text: string, indent?: number (default 2) }. Parses the input; returns the re-serialised, indented JSON, or a clear parse error.",
|
|
55
|
+
"invoke": {
|
|
56
|
+
"type": "script",
|
|
57
|
+
"path": "scripts/json.js"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "text-stats",
|
|
62
|
+
"name": "Text Stats",
|
|
63
|
+
"summary": "Count characters, words, and lines in a block of text.",
|
|
64
|
+
"category": "text",
|
|
65
|
+
"instructions": "Args: { text: string }. Returns { chars, words, lines } computed from the input text.",
|
|
66
|
+
"invoke": {
|
|
67
|
+
"type": "script",
|
|
68
|
+
"path": "scripts/text-stats.js"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "danger",
|
|
73
|
+
"name": "Danger Demo",
|
|
74
|
+
"summary": "Demonstrate a gated, potentially destructive operation.",
|
|
75
|
+
"category": "demo",
|
|
76
|
+
"instructions": "A sample tool that represents a destructive action. It is intended to be exercised behind the gate so the gating path can be tested end to end. Pass { confirm: true } to proceed.",
|
|
77
|
+
"invoke": {
|
|
78
|
+
"type": "script",
|
|
79
|
+
"path": "scripts/danger.js"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "tf_tool_add",
|
|
84
|
+
"name": "tf_tool_add",
|
|
85
|
+
"summary": "Register a new tool entry in the gateway tool register.",
|
|
86
|
+
"category": "management",
|
|
87
|
+
"instructions": "Add a register entry. Args: { id, name, summary?, category?, instructions?, invoke:{type:'script',path:'scripts/<file>'}|{type:'shell',command}, scriptText? }. If scriptText is given for a script invoke, the body is written to tools/scripts/<basename> first. Change is LIVE (no reconnect). Fails if id already exists.",
|
|
88
|
+
"invoke": {
|
|
89
|
+
"type": "script",
|
|
90
|
+
"path": "scripts/tf-tool-add.js"
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"id": "tf_tool_set",
|
|
95
|
+
"name": "tf_tool_set",
|
|
96
|
+
"summary": "Activate, deactivate, promote (hot), hide, or remove a register tool.",
|
|
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).",
|
|
99
|
+
"invoke": {
|
|
100
|
+
"type": "script",
|
|
101
|
+
"path": "scripts/tf-tool-set.js"
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"id": "tf_mcp_add",
|
|
106
|
+
"name": "tf_mcp_add",
|
|
107
|
+
"summary": "Register an upstream MCP (and optionally expose some of its tools).",
|
|
108
|
+
"category": "management",
|
|
109
|
+
"instructions": "Args: { id, command, args?, env?, transport?='stdio', enabled?=true, description?, expose?:[{tool, as?, category?, enabled?}] }. Adds an upstreams[] entry; each expose[] item adds a curated-direct selection. Curated-direct changes need an aggregator reconnect to surface.",
|
|
110
|
+
"invoke": {
|
|
111
|
+
"type": "script",
|
|
112
|
+
"path": "scripts/tf-mcp-add.js"
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"id": "tf_mcp_set",
|
|
117
|
+
"name": "tf_mcp_set",
|
|
118
|
+
"summary": "Enable, disable, or remove an upstream MCP.",
|
|
119
|
+
"category": "management",
|
|
120
|
+
"instructions": "Args: { id, action:'enable'|'disable'|'remove' }. enable/disable toggles the upstream; remove deletes it and cascades its expose entries. Reconnect needed for the curated-direct surface to change.",
|
|
121
|
+
"invoke": {
|
|
122
|
+
"type": "script",
|
|
123
|
+
"path": "scripts/tf-mcp-set.js"
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"id": "tf_hook_add",
|
|
128
|
+
"name": "tf_hook_add",
|
|
129
|
+
"summary": "Add a hook manifest entry (e.g. a PreToolUse gate).",
|
|
130
|
+
"category": "management",
|
|
131
|
+
"instructions": "Args: { id, event, matcher?, command, script?, timeout?, enabled?=true, description?, scriptText? }. id format '<kebab-event>/<name>'. command should use the portable ${HOOKS_DIR} token. If scriptText is given, the script is authored under hooks/scripts/ (path-guarded). enabled is the manifest seed; live toggles live in hooks.state.json.",
|
|
132
|
+
"invoke": {
|
|
133
|
+
"type": "script",
|
|
134
|
+
"path": "scripts/tf-hook-add.js"
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"id": "tf_hook_set",
|
|
139
|
+
"name": "tf_hook_set",
|
|
140
|
+
"summary": "Enable, disable, or remove a hook.",
|
|
141
|
+
"category": "management",
|
|
142
|
+
"instructions": "Args: { id, action:'enable'|'disable'|'remove' }. enable/disable writes the hooks.state.json overlay (wins over the manifest seed) and mirrors the manifest; remove deletes the manifest entry and purges the overlay key (the script file is left in place).",
|
|
143
|
+
"invoke": {
|
|
144
|
+
"type": "script",
|
|
145
|
+
"path": "scripts/tf-hook-set.js"
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"id": "tf_list",
|
|
150
|
+
"name": "tf_list",
|
|
151
|
+
"summary": "List register tools, upstream MCPs, or hooks with their active state.",
|
|
152
|
+
"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.",
|
|
154
|
+
"invoke": {
|
|
155
|
+
"type": "script",
|
|
156
|
+
"path": "scripts/tf-list.js"
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"id": "tf_log",
|
|
161
|
+
"name": "tf_log",
|
|
162
|
+
"summary": "Enable, disable, or check the gateway activity/audit log.",
|
|
163
|
+
"category": "management",
|
|
164
|
+
"instructions": "Args: { action:'enable'|'disable'|'status', path? }. Toggles ToolFunnel's JSONL audit log (tool runs + gate allow/deny decisions). Default OFF. 'status' returns {enabled,path} + recent count. Like any tool, tf_log can itself be disabled to revoke logging control.",
|
|
165
|
+
"invoke": {
|
|
166
|
+
"type": "script",
|
|
167
|
+
"path": "scripts/tf-log.js"
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
}
|