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,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 };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# How to add a hook (self-extension)
|
|
2
|
+
|
|
3
|
+
This is the instruction served by `toolfunnel_howto({ topic: "add-hook" })`. It explains how to add a hook
|
|
4
|
+
to the Hook Engine: the manifest entry shape, where the script goes, and how the enabled/disabled
|
|
5
|
+
state is persisted separately in `hooks.state.json`. The full contract is HOOK_ENGINE.md — this is
|
|
6
|
+
the authoring view.
|
|
7
|
+
|
|
8
|
+
A hook is exactly two things:
|
|
9
|
+
1. **A manifest entry** in `hooks/hooks.manifest.json` (its config: event, matcher, command, …).
|
|
10
|
+
2. **A script** under `hooks/scripts/` that the entry's `command` runs.
|
|
11
|
+
|
|
12
|
+
Activation (on/off) is NOT in the manifest's source of truth at runtime — it lives in an overlay,
|
|
13
|
+
`hooks/hooks.state.json` (see §4).
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. The manifest entry shape
|
|
18
|
+
|
|
19
|
+
`hooks.manifest.json` has `version`, `hooksDir`, and a `hooks` array. Each entry:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"id": "pre-tool-use/deny-dangerous",
|
|
24
|
+
"event": "PreToolUse",
|
|
25
|
+
"matcher": "Bash|toolfunnel_run_tool",
|
|
26
|
+
"type": "command",
|
|
27
|
+
"command": "bash \"${HOOKS_DIR}/scripts/deny-dangerous.sh\"",
|
|
28
|
+
"script": "scripts/deny-dangerous.sh",
|
|
29
|
+
"timeout": 5,
|
|
30
|
+
"enabled": true,
|
|
31
|
+
"description": "Denies any tool call whose args contain a destructive pattern (rm -rf, etc)."
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| Field | Type | Meaning |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `id` | string | Unique key, format `"<kebab-event>/<script-basename-without-ext>"` (e.g. `pre-tool-use/deny-dangerous`). The loader and `hooks.state.json` key on this. |
|
|
38
|
+
| `event` | string | One of the six lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `PreCompact`. |
|
|
39
|
+
| `matcher` | string | Regex anchored full-match against `tool_name`. `""`, `undefined`, or `"*"` = always fire. Tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) ignore it. Example: `"Bash|Write|Edit"`. |
|
|
40
|
+
| `type` | string | `"command"` (the only type today). |
|
|
41
|
+
| `command` | string | The command line the runner spawns. `${HOOKS_DIR}` expands to the absolute `hooks` path at load time. |
|
|
42
|
+
| `script` | string | The script path relative to `hooksDir`, e.g. `scripts/deny-dangerous.sh`. Used by the manager's open/edit (`readScript`/`writeScript`). |
|
|
43
|
+
| `timeout` | number | **Seconds** (the runner multiplies by 1000). The runner kills the child past this. |
|
|
44
|
+
| `enabled` | boolean | The manifest's default/seed state. The live value is the overlay in `hooks.state.json` — see §4. |
|
|
45
|
+
| `description` | string | What the hook does, for the manager UI. |
|
|
46
|
+
|
|
47
|
+
One entry **per hook command** — a settings.json event with N hooks becomes N entries.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 2. Where the script goes
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
hooks/
|
|
55
|
+
hooks.manifest.json # config entries (above)
|
|
56
|
+
hooks.state.json # enabled/disabled overlay, keyed by hook id
|
|
57
|
+
scripts/ # your hook scripts live HERE
|
|
58
|
+
deny-dangerous.sh
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
(The loader itself — `hook-loader.js` — is engine code and is NOT user content; it lives in
|
|
62
|
+
`src/core/`, not in `hooks/`.)
|
|
63
|
+
|
|
64
|
+
`writeScript` (the manager's save path) refuses any path that resolves outside
|
|
65
|
+
`hooks/scripts/` — defense-in-depth for the isolation rule. Author your script there only;
|
|
66
|
+
never point a hook at a script in a live host hooks directory. Clone the behaviour in if you need it.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 3. What the script returns (the two protocols)
|
|
71
|
+
|
|
72
|
+
The runner spawns the command, writes the event payload as JSON on stdin, then reads the result.
|
|
73
|
+
Two protocols (HOOK_ENGINE.md §3):
|
|
74
|
+
|
|
75
|
+
**A) Exit-code (simple):**
|
|
76
|
+
- `exit 0` → success. On `SessionStart`/`UserPromptSubmit`, stdout becomes **injected context**;
|
|
77
|
+
on other events stdout is advisory.
|
|
78
|
+
- `exit 2` → **blocking**. stderr is the reason.
|
|
79
|
+
- any other code → non-blocking error (stderr captured, loop continues; never throws).
|
|
80
|
+
|
|
81
|
+
**B) JSON (advanced)** — honored **only on exit 0**, when stdout parses as a JSON object:
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"continue": true,
|
|
85
|
+
"decision": "block",
|
|
86
|
+
"reason": "…",
|
|
87
|
+
"hookSpecificOutput": {
|
|
88
|
+
"hookEventName": "PreToolUse",
|
|
89
|
+
"permissionDecision": "allow" | "deny" | "ask",
|
|
90
|
+
"permissionDecisionReason": "…",
|
|
91
|
+
"additionalContext": "…"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
- **PreToolUse blocks via `hookSpecificOutput.permissionDecision: "deny"`** — NOT the top-level
|
|
96
|
+
`decision` field. `"allow"` passes; `"ask"` is non-blocking in the autonomous host.
|
|
97
|
+
- **Top-level `decision: "block"`** is for the other blockable events (UserPromptSubmit, PostToolUse,
|
|
98
|
+
Stop, PreCompact). There is no `"approve"`.
|
|
99
|
+
- `continue: false` stops the whole loop. `hookSpecificOutput.additionalContext` → injected text.
|
|
100
|
+
|
|
101
|
+
This is the mechanism that makes a PreToolUse hook gate `toolfunnel_run_tool`: match its `tool_name`, and
|
|
102
|
+
return `permissionDecision: "deny"` to block the call before the tool runs.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 4. Activation state lives in `hooks.state.json`
|
|
107
|
+
|
|
108
|
+
The Hook Manager design splits **what exists** from **what's on**:
|
|
109
|
+
|
|
110
|
+
- **The `scripts/` folder is the source of truth for what exists.** On startup the loader scans it
|
|
111
|
+
and reconciles with the manifest: newly-found scripts are added as entries (event/matcher inferred
|
|
112
|
+
or flagged "unconfigured"); manifest entries whose script is missing are flagged.
|
|
113
|
+
- **The manifest carries config** (event, matcher, command, timeout, description, seed `enabled`).
|
|
114
|
+
- **`hooks.state.json` is the activation overlay** — an object keyed by hook `id` recording the live
|
|
115
|
+
enabled/disabled value:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"version": 1,
|
|
120
|
+
"enabled": {
|
|
121
|
+
"pre-tool-use/deny-dangerous": true,
|
|
122
|
+
"user-prompt-submit/inject-context": false
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Toggling a hook writes `hooks.state.json` **atomically** (temp + rename). It survives restarts and is
|
|
128
|
+
kept separate from the auto-detected inventory, so re-scanning the folder never clobbers your toggles.
|
|
129
|
+
At load, the overlay wins over the manifest's seed `enabled` for any id it lists.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 5. Steps to add a hook
|
|
134
|
+
|
|
135
|
+
1. Write the script into `hooks/scripts/<name>.sh` (or a node script). Read the stdin JSON
|
|
136
|
+
payload; emit one of the two protocols above.
|
|
137
|
+
2. Add the manifest entry: `id` = `"<kebab-event>/<name>"`, set `event`, `matcher`, `command`
|
|
138
|
+
(`bash "${HOOKS_DIR}/scripts/<name>.sh"`), `script`, `timeout` (seconds), `description`, and a
|
|
139
|
+
seed `enabled`.
|
|
140
|
+
3. Set its live state in `hooks.state.json` via the loader's `setEnabled(id, bool)` (atomic write).
|
|
141
|
+
4. Verify: the manager lists it under its event; a run-once test fires it with a sample payload and
|
|
142
|
+
shows the result. For a gate, confirm a matching `toolfunnel_run_tool` call is denied with your reason.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# How to register an upstream MCP (self-extension)
|
|
2
|
+
|
|
3
|
+
This is the instruction served by `toolfunnel_howto({ topic: "add-mcp" })`. It explains how to connect an
|
|
4
|
+
**upstream MCP server** (a browser-automation MCP, a chat bridge, …) to the aggregator and choose **which of its
|
|
5
|
+
tools to expose downstream** to the CLI. All of this is configured in one persisted file:
|
|
6
|
+
`mcp/expose.json`.
|
|
7
|
+
|
|
8
|
+
The architecture (see the architecture overview, §1): many upstream MCPs each expose many tools; the
|
|
9
|
+
aggregator connects to them and the **MCP Manager picks which tools to surface** to the CLI as
|
|
10
|
+
*curated-direct* tools — alongside the four lean meta-tools (`toolfunnel_list_tools`,
|
|
11
|
+
`toolfunnel_tool_instructions`, `toolfunnel_run_tool`, `toolfunnel_howto`). The CLI sees a small surface; everything
|
|
12
|
+
else stays reachable through the register.
|
|
13
|
+
|
|
14
|
+
> Phase note: the live aggregator (`src/mcp/aggregator.js`) is Phase 2. This doc defines the
|
|
15
|
+
> `expose.json` shape so the config is authorable now and the aggregator reads exactly these fields
|
|
16
|
+
> when it lands.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 1. `expose.json` — the two blocks
|
|
21
|
+
|
|
22
|
+
`expose.json` persists (a) the upstream MCP connections and (b) the curated-direct selection drawn
|
|
23
|
+
from them:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"version": 1,
|
|
28
|
+
"upstreams": [
|
|
29
|
+
{
|
|
30
|
+
"id": "chat",
|
|
31
|
+
"transport": "stdio",
|
|
32
|
+
"command": "node",
|
|
33
|
+
"args": ["vendor/chat-mcp/server.js"],
|
|
34
|
+
"env": { "CHAT_DB": "..." },
|
|
35
|
+
"enabled": true,
|
|
36
|
+
"description": "Chat bridge MCP — chats, messages, send."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "browser",
|
|
40
|
+
"transport": "stdio",
|
|
41
|
+
"command": "npx",
|
|
42
|
+
"args": ["@example/browser-mcp"],
|
|
43
|
+
"enabled": false,
|
|
44
|
+
"description": "Browser automation MCP."
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"expose": [
|
|
48
|
+
{
|
|
49
|
+
"upstream": "chat",
|
|
50
|
+
"tool": "list_chats",
|
|
51
|
+
"as": "chat_list_chats",
|
|
52
|
+
"category": "chat",
|
|
53
|
+
"enabled": true
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"upstream": "chat",
|
|
57
|
+
"tool": "send_message",
|
|
58
|
+
"as": "chat_send_message",
|
|
59
|
+
"category": "chat",
|
|
60
|
+
"enabled": true
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### `upstreams[]` — one entry per upstream MCP
|
|
67
|
+
|
|
68
|
+
| Field | Type | Meaning |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `id` | string | Stable key for this upstream. Referenced by `expose[].upstream`. Unique. |
|
|
71
|
+
| `transport` | string | How the aggregator connects. `"stdio"` (spawn `command`+`args`) is the baseline. |
|
|
72
|
+
| `command` / `args` / `env` | string / string[] / object | The process the aggregator spawns for a `stdio` upstream. Paths must point **inside the project's vendor directory** (clone vendored servers in; never reference servers configured elsewhere on the host). |
|
|
73
|
+
| `enabled` | boolean | Whether the aggregator connects to this upstream at startup. |
|
|
74
|
+
| `description` | string | Human-facing note for the MCP Manager UI. |
|
|
75
|
+
|
|
76
|
+
### `expose[]` — which upstream tools become curated-direct
|
|
77
|
+
|
|
78
|
+
| Field | Type | Meaning |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `upstream` | string | Must match an `upstreams[].id`. |
|
|
81
|
+
| `tool` | string | The upstream tool's own name (as the upstream's `tools/list` reports it). |
|
|
82
|
+
| `as` | string | The downstream name the CLI sees (namespaced so two upstreams can't collide, e.g. `chat_send_message`). Optional — defaults to `<upstream>_<tool>`. |
|
|
83
|
+
| `category` | string | Grouping label, same convention as register entries. |
|
|
84
|
+
| `enabled` | boolean | Whether this tool is actually exposed downstream right now. |
|
|
85
|
+
|
|
86
|
+
Only the tools listed in `expose[]` with `enabled: true` are surfaced to the CLI as curated-direct
|
|
87
|
+
tools. Everything else on the upstream stays connected but hidden — reachable, if needed, by adding
|
|
88
|
+
an `expose` entry (no upstream reconnect required to *list*, though see §3 on the CLI surface).
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 2. Steps to register an upstream and expose a tool
|
|
93
|
+
|
|
94
|
+
1. Add an entry to `upstreams[]` with a unique `id`, the `stdio` `command`/`args`, and
|
|
95
|
+
`enabled: true`. Vendor the server **inside the project's vendor directory** if it isn't already.
|
|
96
|
+
2. Let the aggregator connect and run the upstream's `tools/list` (the MCP Manager UI surfaces the
|
|
97
|
+
discovered tools).
|
|
98
|
+
3. For each tool you want hot, add an `expose[]` entry: `{ upstream, tool, as, category, enabled:true }`.
|
|
99
|
+
4. Leave the long tail unexposed — the model can still reach those via the register/meta-tools if you
|
|
100
|
+
later mirror them, but the *curated-direct* surface stays lean by design (the dial in §1 of the
|
|
101
|
+
architecture: directly-exposed = the HOT tools; the register meta-tools = the long tail).
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 3. Curated-direct vs. the register (update cost)
|
|
106
|
+
|
|
107
|
+
This matters because the two layers refresh differently (see the architecture overview, §1):
|
|
108
|
+
|
|
109
|
+
- **Curated-direct tools** (what `expose[]` produces) are negotiated with the CLI **at connect time**.
|
|
110
|
+
Changing the exposed set therefore needs a **reconnect**, or a `notifications/tools/list_changed`
|
|
111
|
+
message *if the CLI honours it* (VERIFY per host; restart is the safe baseline).
|
|
112
|
+
- **Register tools** (the `toolfunnel_list_tools`/`run` path) are **always live** — the model re-reads the
|
|
113
|
+
register on every `toolfunnel_list_tools` call, so no reconnect is needed.
|
|
114
|
+
|
|
115
|
+
Rule of thumb: expose a handful of HOT, high-reliability tools curated-direct; everything else lives
|
|
116
|
+
in the register and is reached through the meta-tools.
|
|
117
|
+
|
|
118
|
+
> Safety: a curated-direct upstream tool is still ultimately invoked by the model. When you wire
|
|
119
|
+
> upstream execution through the host, route it through the same PreToolUse gate the register's `run`
|
|
120
|
+
> uses (see `toolfunnel_howto({ topic: "add-hook" })`) so the safety invariant holds uniformly. The gate
|
|
121
|
+
> firing on every execution path is a tested invariant, not an assumption.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# How to add a tool (self-extension)
|
|
2
|
+
|
|
3
|
+
This is the instruction served by `toolfunnel_howto({ topic: "create-tool" })`. It tells you — the
|
|
4
|
+
model — how to add a new tool to the register so it becomes callable through `toolfunnel_run_tool`
|
|
5
|
+
(and listable via `toolfunnel_list_tools` / `toolfunnel_tool_instructions`). No restart is needed: the register
|
|
6
|
+
is read live every time the meta-tools are called.
|
|
7
|
+
|
|
8
|
+
A tool is exactly two things:
|
|
9
|
+
1. **A register entry** in `tools/tools.register.json` (structured metadata + how to invoke it).
|
|
10
|
+
2. **A script** under `tools/scripts/` that the entry's `invoke` points at (only for
|
|
11
|
+
`invoke.type === "script"`; a `shell` tool needs no file).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. The register entry shape
|
|
16
|
+
|
|
17
|
+
`tools.register.json` is a JSON file with one top-level array of entries. Each entry:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"id": "echo-upper",
|
|
22
|
+
"name": "Echo Upper",
|
|
23
|
+
"summary": "Uppercases the supplied text and echoes it back.",
|
|
24
|
+
"category": "demo",
|
|
25
|
+
"instructions": "Pass { text: string }. Returns the text uppercased. Use this only as a sanity check of the run path.",
|
|
26
|
+
"invoke": { "type": "script", "path": "scripts/echo-upper.sh" }
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Field-by-field — these names are fixed (registry.js reads exactly these):
|
|
31
|
+
|
|
32
|
+
| Field | Type | Meaning |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `id` | string | Stable unique key, kebab-case. `toolfunnel_run_tool({ name })` and `registry.run(id, args)` look up by this. Never reuse an existing `id`. |
|
|
35
|
+
| `name` | string | Human/model-facing display name. Shown in the manager UI. |
|
|
36
|
+
| `summary` | string | One line. This is ALL that `toolfunnel_list_tools` returns for the tool — keep it tight and discriminating so the model can pick the right tool from the brief alone. |
|
|
37
|
+
| `category` | string | Grouping label (e.g. `demo`, `memory`, `messaging`, `media`). Used by `toolfunnel_list_tools({ category })` to filter. |
|
|
38
|
+
| `instructions` | string | Full usage doc returned by `toolfunnel_tool_instructions({ name })`. Describe the args object, what comes back, and any safety caveats. This is the long-tail payload — be complete here, terse in `summary`. |
|
|
39
|
+
| `invoke` | object | How `run` executes the tool. See below. |
|
|
40
|
+
|
|
41
|
+
### The `invoke` object — two forms
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{ "type": "script", "path": "scripts/echo-upper.sh" }
|
|
45
|
+
```
|
|
46
|
+
- `type: "script"` — run a file under `tools/scripts/`. `path` is **relative to `tools/`**
|
|
47
|
+
and MUST stay inside `tools/scripts/` (registry.js path-guards it, same defense-in-depth as the
|
|
48
|
+
hook loader's `writeScript`). The args object is delivered to the script (e.g. as a JSON blob on
|
|
49
|
+
stdin or argv per the registry's convention) and the script's stdout is the tool output.
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{ "type": "shell", "command": "node \"${TOOLS_DIR}/scripts/echo-upper.js\"" }
|
|
53
|
+
```
|
|
54
|
+
- `type: "shell"` — run a command line. `${TOOLS_DIR}` expands to the absolute `tools` path at run
|
|
55
|
+
time (mirrors `${HOOKS_DIR}` in the hook manifest). Prefer `script` for anything reusable; reserve
|
|
56
|
+
`shell` for thin one-liners.
|
|
57
|
+
|
|
58
|
+
> Isolation rule: an `invoke` must only reference scripts/commands that live **inside the gateway's
|
|
59
|
+
> own tree**. Never point `invoke` at a live tool that lives outside it (e.g. in a host's external
|
|
60
|
+
> tool directory). If you want an external tool's behaviour, clone its script into
|
|
61
|
+
> `tools/scripts/` first, then point at the clone.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 2. Where the script goes
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
tools/
|
|
69
|
+
tools.register.json # the entries (above)
|
|
70
|
+
scripts/ # your tool scripts live HERE and nowhere else
|
|
71
|
+
echo-upper.sh
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
(`registry.js` — the engine that loads/persists the register and runs list/instructions/run — is
|
|
75
|
+
NOT user content. It lives in `src/tools/registry.js` and is not part of this tree.)
|
|
76
|
+
|
|
77
|
+
The script reads its arguments (the `args` object passed to `run`) and writes its result to stdout.
|
|
78
|
+
Exit non-zero with a message on stderr to signal failure — the registry surfaces that as the tool's
|
|
79
|
+
error. Keep scripts pure and side-effect-honest: anything destructive is exactly what the PreToolUse
|
|
80
|
+
gate exists to catch (next section).
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 3. How `run` works — and why it is gated
|
|
85
|
+
|
|
86
|
+
`toolfunnel_run_tool({ name, args })` does **not** execute the script directly. It calls
|
|
87
|
+
`registry.run(id, args)`, which routes the call through the gateway's **hook engine** before — and after —
|
|
88
|
+
the tool actually runs:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
toolfunnel_run_tool(name, args)
|
|
92
|
+
│
|
|
93
|
+
▼
|
|
94
|
+
registry.run(id, args)
|
|
95
|
+
│
|
|
96
|
+
├─► hookEngine.fire("PreToolUse", ctx, { tool_name, tool_input: args })
|
|
97
|
+
│ └─ if result.blocked → STOP. Return { ok:false, error: <reason> }. Script never runs.
|
|
98
|
+
│
|
|
99
|
+
├─► execute the invoke (script/shell) only if NOT blocked
|
|
100
|
+
│
|
|
101
|
+
└─► hookEngine.fire("PostToolUse", ctx, { tool_name, tool_input: args, tool_response })
|
|
102
|
+
└─ feedback only; the tool already ran (cannot un-run it)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- **The PreToolUse gate is the whole safety case.** A hook denies a call by returning, on exit 0,
|
|
106
|
+
`{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny",
|
|
107
|
+
"permissionDecisionReason": "…" } }` — OR by `exit 2` with the reason on stderr. (PreToolUse uses
|
|
108
|
+
`permissionDecision`, NOT a top-level `decision` — see HOOK_ENGINE.md §3.) When that fires,
|
|
109
|
+
`run` returns an error result and the script is never spawned.
|
|
110
|
+
- `permissionDecision: "allow"` passes; `"ask"` is non-blocking in the autonomous host (no
|
|
111
|
+
interactive prompt) — the reason is captured but the tool proceeds.
|
|
112
|
+
- The result shape `run` returns matches the meta-tool contract: `{ ok, output, error? }`.
|
|
113
|
+
|
|
114
|
+
So adding a tool grants real power, but every invocation is mediated by the same gate that protects
|
|
115
|
+
every other tool. If your new tool is dangerous, the correct control is a PreToolUse hook that
|
|
116
|
+
matches its `tool_name` (see `toolfunnel_howto({ topic: "add-hook" })`), not omitting the tool.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 4. Steps to add a tool
|
|
121
|
+
|
|
122
|
+
1. Pick a unique `id` (check `toolfunnel_list_tools` first so you don't collide).
|
|
123
|
+
2. If `invoke.type === "script"`, write the script into `tools/scripts/<id>.sh` (or `.js`).
|
|
124
|
+
3. Add the entry to the `tools.register.json` array (via `registry.js`'s add/edit path so the write
|
|
125
|
+
is atomic — never hand-edit and risk a torn file).
|
|
126
|
+
4. Confirm with `toolfunnel_list_tools` (your `summary` appears) and `toolfunnel_tool_instructions({ name })`
|
|
127
|
+
(your `instructions` appear).
|
|
128
|
+
5. Test with `toolfunnel_run_tool({ name, args })`. Verify the gate behaves: a benign call returns
|
|
129
|
+
`{ ok:true, output }`; if a matching PreToolUse hook should deny it, confirm it does.
|
|
130
|
+
|
|
131
|
+
No reconnect is needed — the register is dynamic. (Only *curated-direct* tools negotiated at MCP
|
|
132
|
+
connect need a reconnect; register tools reached via the meta-tools are always live.)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* howto.js — the loader behind the `toolfunnel_howto` meta-tool (architecture notes §2 + §6).
|
|
5
|
+
*
|
|
6
|
+
* `toolfunnel_howto({ topic })` returns the self-extension instructions for one topic. This module
|
|
7
|
+
* resolves a topic to its authored markdown file (the topic -> file map below) and reads it.
|
|
8
|
+
*
|
|
9
|
+
* howto(topic) -> string // the markdown content for that topic
|
|
10
|
+
*
|
|
11
|
+
* Topics (the four documented in §2): create-tool | add-mcp | add-hook | package.
|
|
12
|
+
* Unknown topics THROW (the protocol layer turns that into a clean tool error, not a crash).
|
|
13
|
+
*
|
|
14
|
+
* Isolation: reads are confined to THIS directory (src/extend). The topic map is a fixed
|
|
15
|
+
* allow-list of basenames — there is no path interpolation from the caller, so a malicious
|
|
16
|
+
* `topic` cannot traverse out of src/extend. We additionally re-verify the resolved path is
|
|
17
|
+
* inside this directory (defense-in-depth, mirroring the hook loader's writeScript guard).
|
|
18
|
+
*
|
|
19
|
+
* CommonJS only. Node built-ins only. No transport, pure file read — unit-testable directly.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
// The directory these instruction files live in (this module's own directory).
|
|
26
|
+
const EXTEND_DIR = __dirname;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Fixed topic -> filename map. The set of keys IS the list of valid topics; the values are
|
|
30
|
+
* plain basenames (no separators) so nothing the caller passes is ever joined into a path.
|
|
31
|
+
* @type {Readonly<Record<string,string>>}
|
|
32
|
+
*/
|
|
33
|
+
const TOPIC_FILES = Object.freeze({
|
|
34
|
+
'create-tool': 'create-tool.md',
|
|
35
|
+
'add-mcp': 'add-mcp.md',
|
|
36
|
+
'add-hook': 'add-hook.md',
|
|
37
|
+
package: 'package.md',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The valid topic names, for error messages and for callers that want to enumerate
|
|
42
|
+
* (e.g. the protocol layer validating args, or a UI listing the howto topics).
|
|
43
|
+
* @returns {string[]}
|
|
44
|
+
*/
|
|
45
|
+
function topics() {
|
|
46
|
+
return Object.keys(TOPIC_FILES);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a topic to the instruction markdown content.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} topic one of: create-tool | add-mcp | add-hook | package
|
|
53
|
+
* @returns {string} the UTF-8 markdown content of the matching file
|
|
54
|
+
* @throws {Error} if `topic` is not a string, is unknown, or (defensively) resolves outside
|
|
55
|
+
* src/extend or the file is missing.
|
|
56
|
+
*/
|
|
57
|
+
function howto(topic) {
|
|
58
|
+
if (typeof topic !== 'string' || topic.length === 0) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`howto: topic must be a non-empty string. Known topics: ${topics().join(', ')}`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Look up against the fixed allow-list — own property only (guards against "constructor",
|
|
65
|
+
// "__proto__", etc. resolving via the prototype chain).
|
|
66
|
+
if (!Object.prototype.hasOwnProperty.call(TOPIC_FILES, topic)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`howto: unknown topic "${topic}". Known topics: ${topics().join(', ')}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fileName = TOPIC_FILES[topic];
|
|
73
|
+
const filePath = path.join(EXTEND_DIR, fileName);
|
|
74
|
+
|
|
75
|
+
// Defense-in-depth: confirm the resolved path is still inside EXTEND_DIR. With a fixed
|
|
76
|
+
// basename map this can never fail, but the guard documents and enforces the isolation rule.
|
|
77
|
+
const rel = path.relative(EXTEND_DIR, filePath);
|
|
78
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
79
|
+
throw new Error(`howto: refusing to read outside src/extend ("${fileName}")`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// A known topic whose file is missing is a packaging error, not an unknown-topic error.
|
|
86
|
+
throw new Error(
|
|
87
|
+
`howto: instruction file for topic "${topic}" is missing or unreadable ` +
|
|
88
|
+
`(${filePath}): ${(err && err.message) || String(err)}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
howto,
|
|
95
|
+
topics,
|
|
96
|
+
TOPIC_FILES,
|
|
97
|
+
EXTEND_DIR,
|
|
98
|
+
};
|