toolfunnel 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -213
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -225
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +70 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +31 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +192 -12
- package/package.json +1 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +344 -344
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +275 -275
- package/src/core/hook-loader.js +791 -791
- package/src/core/hook-runner.js +346 -346
- package/src/core/logger.js +171 -171
- package/src/core/matcher.js +51 -51
- package/src/core/metrics.js +75 -75
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/core/tool-registry.js +180 -180
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +122 -121
- package/src/extend/configure.md +87 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +187 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1246 -807
- package/src/mcp/expose-store.js +460 -430
- package/src/mcp/gated-run.js +201 -194
- package/src/mcp/http-transport.js +1311 -981
- package/src/mcp/mcp-client.js +907 -530
- package/src/mcp/modern.js +372 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +2688 -1322
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -511
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1174 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1348 -1190
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +112 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -216
- package/docs/admin-ui.md +0 -404
- package/docs/configuration.md +0 -402
- package/docs/hooks-and-gating.md +0 -428
- package/docs/logging.md +0 -223
- package/docs/management.md +0 -429
- package/docs/oauth.md +0 -103
- package/docs/releasing.md +0 -31
- package/docs/setup.md +0 -352
- package/docs/tools-and-upstreams.md +0 -469
|
@@ -1,469 +0,0 @@
|
|
|
1
|
-
# Extending ToolFunnel: tools and upstreams
|
|
2
|
-
|
|
3
|
-
How to grow the gateway's tool surface — by adding **first-party tools** you host
|
|
4
|
-
yourself, or by **curating tools from upstream MCP servers** — without adding a single
|
|
5
|
-
npm dependency and without losing the lean-register / execution-gate guarantees.
|
|
6
|
-
|
|
7
|
-
This is the long-form companion to the per-topic instructions the model can pull at
|
|
8
|
-
runtime via `toolfunnel_howto({ topic })`:
|
|
9
|
-
|
|
10
|
-
| Topic | Served file | Covers |
|
|
11
|
-
|---|---|---|
|
|
12
|
-
| `create-tool` | `src/extend/create-tool.md` | adding a first-party tool |
|
|
13
|
-
| `add-mcp` | `src/extend/add-mcp.md` | registering + curating an upstream MCP |
|
|
14
|
-
| `add-hook` | `src/extend/add-hook.md` | the PreToolUse / PostToolUse gate |
|
|
15
|
-
| `package` | `src/extend/package.md` | packaging a tool for distribution |
|
|
16
|
-
|
|
17
|
-
**Clean structure — engine vs. your data.** ToolFunnel keeps its OWN engine code under
|
|
18
|
-
`src/` (you never edit it) and your configuration/data at the top level (you edit it freely):
|
|
19
|
-
|
|
20
|
-
| You edit (top-level) | Engine code (under `src/`, read-only) |
|
|
21
|
-
|---|---|
|
|
22
|
-
| `tools/tools.register.json` — the tool register | `src/tools/registry.js` — loads/persists the register |
|
|
23
|
-
| `tools/tools.state.json` — enable/disable overlay | `src/tools/tool-state.js` — the overlay logic |
|
|
24
|
-
| `tools/scripts/*.js` — your first-party tool scripts | — |
|
|
25
|
-
| `mcp/expose.json` — upstream + curation config | `src/mcp/expose-store.js` — the config store |
|
|
26
|
-
| `hooks/hooks.manifest.json` — the gate | `src/mcp/aggregator.js` — live upstream + curated-expose |
|
|
27
|
-
|
|
28
|
-
The engine resolves all of these relative to the gateway root (`src/mcp/server.js` anchors
|
|
29
|
-
`ROOT = …/toolfunnel` and reads `tools/tools.register.json`, `tools/tools.state.json`,
|
|
30
|
-
`tools/scripts`, `mcp/expose.json`, `hooks/hooks.manifest.json`). Everything below was read
|
|
31
|
-
from the source — the field names, defaults, and guards are exactly what the code enforces.
|
|
32
|
-
|
|
33
|
-
---
|
|
34
|
-
|
|
35
|
-
## 1. Adding a first-party tool
|
|
36
|
-
|
|
37
|
-
A first-party tool is exactly **two things**:
|
|
38
|
-
|
|
39
|
-
1. **A script** under `tools/scripts/` that does the work.
|
|
40
|
-
2. **A register entry** in `tools/tools.register.json` that names it and points
|
|
41
|
-
`invoke` at the script.
|
|
42
|
-
|
|
43
|
-
No MCP reconnect is needed (§1.6). Put the file in `tools/scripts/`, add the entry, and the
|
|
44
|
-
tool is reachable through the meta-tools — the advertised MCP surface never changes.
|
|
45
|
-
|
|
46
|
-
### 1.1 The script contract
|
|
47
|
-
|
|
48
|
-
The default executor (`src/tools/registry.js::defaultRunScript`) spawns `node`
|
|
49
|
-
(`process.execPath`) on your script with the structured args serialised into one
|
|
50
|
-
environment variable. It captures stdout/stderr and resolves
|
|
51
|
-
`{ ok: code === 0, code, stdout, stderr }`. It **never rejects on a non-zero exit** — only on
|
|
52
|
-
a genuine spawn failure (the child `error` event). Your script's job:
|
|
53
|
-
|
|
54
|
-
> **Read the structured args from `process.env.TOOLFUNNEL_TOOL_ARGS` (a JSON string),
|
|
55
|
-
> print exactly ONE JSON line to stdout, and `process.exit(0)`.**
|
|
56
|
-
|
|
57
|
-
Three rules make a well-behaved tool, all visible in the demo scripts in `tools/scripts/`:
|
|
58
|
-
|
|
59
|
-
1. **Args arrive as JSON in `TOOLFUNNEL_TOOL_ARGS`.** The runner sets it to
|
|
60
|
-
`JSON.stringify(args ?? null)`. Parse it defensively — if it's absent treat it as
|
|
61
|
-
`null`; if it won't parse, report that as an error result rather than throwing.
|
|
62
|
-
2. **Output is a single JSON object on stdout, newline-terminated.** The runner captures
|
|
63
|
-
stdout verbatim; one clean JSON line is the whole result. By convention success is
|
|
64
|
-
`{ ok: true, ... }` and a handled failure is `{ ok: false, error: "..." }`.
|
|
65
|
-
3. **Always `exit(0)`, even on bad input.** Surface bad input as `{ ok:false, error }` —
|
|
66
|
-
do **not** throw and do **not** exit non-zero for ordinary user error. A non-zero exit
|
|
67
|
-
/ spawn failure is reserved for genuine crashes, which the runner reports back as
|
|
68
|
-
`{ ok:false, code, stderr }`.
|
|
69
|
-
|
|
70
|
-
Other invariants the demos hold to (worth copying): node **built-ins only** (no npm), and
|
|
71
|
-
be **side-effect-honest** — `hash.js` and `text-stats.js` touch nothing but the CPU; the
|
|
72
|
-
one demo with a side effect, `danger.js`, writes only to the file named by its own env var
|
|
73
|
-
(`TOOLFUNNEL_DANGER_LOG`) and exists specifically to prove the gate can stop it.
|
|
74
|
-
|
|
75
|
-
Concrete skeleton, lifted from the structure of `hash.js` / `text-stats.js`:
|
|
76
|
-
|
|
77
|
-
```js
|
|
78
|
-
#!/usr/bin/env node
|
|
79
|
-
'use strict';
|
|
80
|
-
|
|
81
|
-
/** Parse the structured args handed in via env TOOLFUNNEL_TOOL_ARGS. */
|
|
82
|
-
function parseStructuredArgs() {
|
|
83
|
-
const raw = process.env.TOOLFUNNEL_TOOL_ARGS;
|
|
84
|
-
if (raw === undefined || raw === null || raw === '') return { value: null };
|
|
85
|
-
try {
|
|
86
|
-
return { value: JSON.parse(raw) };
|
|
87
|
-
} catch (_err) {
|
|
88
|
-
return { parseError: `TOOLFUNNEL_TOOL_ARGS was not valid JSON: ${String(raw)}` };
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Pure core: parsed args -> JSON-serialisable result. Never throws. */
|
|
93
|
-
function run(args) {
|
|
94
|
-
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
95
|
-
return { ok: false, error: 'args must be an object { text }' };
|
|
96
|
-
}
|
|
97
|
-
const { text } = args;
|
|
98
|
-
if (typeof text !== 'string') return { ok: false, error: 'text must be a string' };
|
|
99
|
-
return { ok: true, upper: text.toUpperCase() };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function main() {
|
|
103
|
-
const parsed = parseStructuredArgs();
|
|
104
|
-
let payload;
|
|
105
|
-
if (parsed.parseError) {
|
|
106
|
-
payload = { ok: false, error: parsed.parseError };
|
|
107
|
-
} else {
|
|
108
|
-
try {
|
|
109
|
-
payload = run(parsed.value);
|
|
110
|
-
} catch (err) {
|
|
111
|
-
payload = { ok: false, error: `unexpected error: ${(err && err.message) || String(err)}` };
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
process.stdout.write(JSON.stringify(payload) + '\n'); // exactly one JSON line
|
|
115
|
-
process.exit(0);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
main();
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
### 1.2 Where the script goes
|
|
122
|
-
|
|
123
|
-
The engine code and your data live in different trees. The script goes with your data:
|
|
124
|
-
|
|
125
|
-
```
|
|
126
|
-
src/tools/
|
|
127
|
-
registry.js # ENGINE — loads/persists the register; list / instructions / resolveExecution
|
|
128
|
-
tool-state.js # ENGINE — the enable/disable overlay logic
|
|
129
|
-
tools/ # YOUR data — edit freely
|
|
130
|
-
tools.register.json # the entries (§1.3)
|
|
131
|
-
tools.state.json # enable/disable overlay (§1.4)
|
|
132
|
-
scripts/ # your tool scripts live HERE and NOWHERE else
|
|
133
|
-
echo.js hash.js text-stats.js … echo-upper.js <- new one
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
The scripts directory is a hard boundary. `defaultRunScript` resolves the target with
|
|
137
|
-
`path.resolve(scriptsRoot, path.basename(invoke.path))` (where `scriptsRoot` is
|
|
138
|
-
`tools/scripts`) and then re-checks that the result is still inside `scriptsRoot` — a
|
|
139
|
-
register entry **cannot** point `invoke.path` at a file outside `tools/scripts/`.
|
|
140
|
-
`path.basename` strips any directory traversal, and the relative-path check rejects anything
|
|
141
|
-
that still escapes. (This is the same defense-in-depth as the upstream isolation guard in
|
|
142
|
-
§2.3.) Practically: put the file in `tools/scripts/`, name `invoke.path` `"scripts/<file>"`,
|
|
143
|
-
done.
|
|
144
|
-
|
|
145
|
-
### 1.3 The register entry
|
|
146
|
-
|
|
147
|
-
Add one object to the `tools` array in `tools/tools.register.json` (whose top-level shape is
|
|
148
|
-
`{ version, description, tools: [] }`):
|
|
149
|
-
|
|
150
|
-
```json
|
|
151
|
-
{
|
|
152
|
-
"id": "echo-upper",
|
|
153
|
-
"name": "Echo Upper",
|
|
154
|
-
"summary": "Uppercase the supplied text and echo it back.",
|
|
155
|
-
"category": "text",
|
|
156
|
-
"instructions": "Args: { text: string }. Returns { ok:true, upper } with the text uppercased, or { ok:false, error } on bad input. No side effects.",
|
|
157
|
-
"invoke": { "type": "script", "path": "scripts/echo-upper.js" }
|
|
158
|
-
}
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
Fields are fixed — `registry.js` reads exactly these (`validateEntry` enforces the
|
|
162
|
-
required ones):
|
|
163
|
-
|
|
164
|
-
| Field | Type | Meaning |
|
|
165
|
-
|---|---|---|
|
|
166
|
-
| `id` | string (required, unique) | Stable kebab-case key. Loading rejects a duplicate `id`. This is what `toolfunnel_run_tool` and `toolfunnel_tool_instructions` look up by (the MCP adapter also accepts the display `name`). |
|
|
167
|
-
| `name` | string (required) | Display name shown in the manager UI. |
|
|
168
|
-
| `summary` | string | **The only thing `toolfunnel_list_tools` returns for this tool.** One tight, discriminating line — see §3 on why brevity here is the token win. |
|
|
169
|
-
| `category` | string | Grouping label (`demo`, `text`, `crypto`, `data`, …). `toolfunnel_list_tools({ category })` filters on it. |
|
|
170
|
-
| `instructions` | string | The full usage doc returned by `toolfunnel_tool_instructions({ id })`. Describe the args object, the result shape, and any caveats. This is the long-tail payload — be complete here, terse in `summary`. |
|
|
171
|
-
| `mode` | string (optional) | `gateway` (ToolFunnel runs the `invoke` server-side through the gate — the default for a `script`/`shell` invoke) or `reference` (ToolFunnel runs nothing; `toolfunnel_run_tool` returns the `instructions` for the connected AI to act on). Absent ⇒ inferred from `invoke`. See §1.7. |
|
|
172
|
-
| `invoke` | object (required unless `mode:"reference"`) | How the tool runs. `{ type:"script", path:"scripts/<file>" }` or `{ type:"shell", command:"…" }`. |
|
|
173
|
-
|
|
174
|
-
**Two `invoke` forms:**
|
|
175
|
-
|
|
176
|
-
- `{ "type": "script", "path": "scripts/x.js" }` — the common case. Runs the host-local
|
|
177
|
-
script (§1.1/§1.2). The script is always run with `node`, so use `.js`.
|
|
178
|
-
- `{ "type": "shell", "command": "…" }` — a thin command line. Note that
|
|
179
|
-
`registry.resolveExecution` does **not** spawn a shell itself; it returns a deferred
|
|
180
|
-
descriptor `{ deferred:true, type:'shell', command, args }`. The server's execute thunk
|
|
181
|
-
performs the actual shell spawn (`spawnSync` with `shell:true`, `cwd` = gateway root,
|
|
182
|
-
`TOOLFUNNEL_TOOL_ARGS` in env) — and `gatedRun` only calls that thunk **after** the
|
|
183
|
-
PreToolUse gate allows, so the broadest power (arbitrary shell) only ever executes behind
|
|
184
|
-
the gate. Prefer `script` for anything reusable; reserve `shell` for one-liners.
|
|
185
|
-
|
|
186
|
-
**Write it atomically, don't hand-tear the file.** `registry.js` exposes
|
|
187
|
-
`add(entry)` / `update(id, patch)` / `remove(id)`, each of which validates and then
|
|
188
|
-
persists via `atomicWriteJson` (temp file in the same dir → fsync → rename). A crash leaves
|
|
189
|
-
either the old or the new register, never a half-written one. If you must edit the JSON by
|
|
190
|
-
hand, keep it valid — a malformed register throws at load time (by design: misconfig is
|
|
191
|
-
caught at startup, not at model-call time).
|
|
192
|
-
|
|
193
|
-
### 1.4 Enable/disable overlay
|
|
194
|
-
|
|
195
|
-
`tools/tools.state.json` is a **default-ON** enable/disable overlay (logic in
|
|
196
|
-
`src/tools/tool-state.js`). Its shape is `{ "<toolId>": { "enabled": false }, … }` — a tool
|
|
197
|
-
absent from the overlay is ACTIVE, and only an explicit `{ "enabled": false }` disables it,
|
|
198
|
-
so newly-added tools are live until you untick them and the file only lists the tools you've
|
|
199
|
-
toggled. `toolfunnel_list_tools` drops DISABLED tools from the briefs, so toggling a tool off
|
|
200
|
-
hides it from the model without deleting its register entry. (There is also an independent
|
|
201
|
-
`hidden` flag that declutters the manager UI only — it never affects what the client sees.)
|
|
202
|
-
Use the overlay to retire a tool temporarily; remove the register entry to retire it
|
|
203
|
-
permanently.
|
|
204
|
-
|
|
205
|
-
### 1.5 The tool runs gated — always
|
|
206
|
-
|
|
207
|
-
`toolfunnel_run_tool({ id, args })` never spawns your script directly. The call funnels
|
|
208
|
-
through `src/mcp/gated-run.js::gatedRun`, which:
|
|
209
|
-
|
|
210
|
-
1. fires **PreToolUse** with `{ tool_name, tool_input: args }` — if a hook **blocks**, the
|
|
211
|
-
script is **never executed** (`execute()` is not called — the load-bearing, tested
|
|
212
|
-
invariant) and the call returns `{ ok:false, blocked:true, reason, output:null }`.
|
|
213
|
-
The gate **fails closed**: if the hook engine rejects or returns a non-object, PreToolUse
|
|
214
|
-
is treated as a block.
|
|
215
|
-
2. runs your script only if allowed (a thrown execute is captured as
|
|
216
|
-
`{ ok:false, blocked:false, error, output:null }`);
|
|
217
|
-
3. fires **PostToolUse** (advisory — it observes the outcome, even on the error path, but
|
|
218
|
-
cannot un-run the tool or flip `ok`).
|
|
219
|
-
|
|
220
|
-
So adding a tool grants real power, but every invocation is mediated by the same gate as
|
|
221
|
-
every other tool. If your tool is dangerous, the correct control is a PreToolUse hook that
|
|
222
|
-
matches its name (`toolfunnel_howto({ topic: "add-hook" })`) — not omitting the tool.
|
|
223
|
-
|
|
224
|
-
### 1.6 Liveness — when changes take effect
|
|
225
|
-
|
|
226
|
-
- **No MCP reconnect for register changes.** The four meta-tools are a *fixed* advertised
|
|
227
|
-
surface, so adding or removing a register entry never alters the tool list the client
|
|
228
|
-
negotiated at connect — the new tool is reached through the same `toolfunnel_list_tools` /
|
|
229
|
-
`toolfunnel_run_tool` meta-tools. (Contrast curated-direct upstream tools in §2.5, which
|
|
230
|
-
ARE advertised individually and DO need a reconnect.)
|
|
231
|
-
- **Enable/disable toggles are instant.** The overlay (`tools/tools.state.json`) is re-read
|
|
232
|
-
fresh on every `toolfunnel_list_tools` call, so ticking a tool on or off takes effect with
|
|
233
|
-
no restart.
|
|
234
|
-
- **Register entries are loaded once per server start** and mutated atomically through the
|
|
235
|
-
Registry `add`/`update`/`remove` API (the MCP Manager UI persists edits the same way). A
|
|
236
|
-
brand-new entry written by hand to the JSON while the server is already running is picked
|
|
237
|
-
up on the next start.
|
|
238
|
-
|
|
239
|
-
### 1.7 Execution mode — gateway vs reference
|
|
240
|
-
|
|
241
|
-
A first-party entry carries an optional `mode` (`src/tools/registry.js::resolveMode`) that decides
|
|
242
|
-
**who actually runs the tool**:
|
|
243
|
-
|
|
244
|
-
- **`gateway`** — ToolFunnel runs the `invoke` **itself**, server-side, through the gate (§1.5).
|
|
245
|
-
This is the default for any `script`/`shell` invoke, and everything above describes a gateway
|
|
246
|
-
tool.
|
|
247
|
-
- **`reference`** — ToolFunnel runs **nothing**. `toolfunnel_run_tool` returns the tool's
|
|
248
|
-
`instructions` and the connected AI performs the action in **its own** environment. A reference
|
|
249
|
-
tool needs no `invoke` — it is a gate-visible, documented pointer to something the model runs
|
|
250
|
-
itself.
|
|
251
|
-
|
|
252
|
-
When `mode` is omitted it is **inferred backward-compatibly**: a `script`/`shell` invoke ⇒
|
|
253
|
-
`gateway`, anything else ⇒ `reference` — so every existing entry keeps its original behaviour.
|
|
254
|
-
Reach for `reference` to advertise a capability the model already has (or a host-side action you'd
|
|
255
|
-
rather it perform) without ToolFunnel executing it; keep the default `gateway` when ToolFunnel
|
|
256
|
-
should run and gate the work. See [`management.md`](management.md) for the full execution-mode
|
|
257
|
-
treatment.
|
|
258
|
-
|
|
259
|
-
### 1.8 Managing tools, MCPs, and hooks at runtime
|
|
260
|
-
|
|
261
|
-
You don't have to hand-edit JSON. Tools, upstream MCPs, and hooks can all be managed **at runtime**
|
|
262
|
-
two ways:
|
|
263
|
-
|
|
264
|
-
- **The `tf_*` management functions** — eight register tools (category `management`; full reference
|
|
265
|
-
in [`management.md`](management.md)) reached through `toolfunnel_run_tool`: `tf_tool_add` /
|
|
266
|
-
`tf_tool_set` (register, then enable / disable / remove a tool), `tf_mcp_add` / `tf_mcp_set` (add,
|
|
267
|
-
toggle, or remove an upstream and its curated-direct exposures), `tf_hook_add` / `tf_hook_set`
|
|
268
|
-
(add, toggle, or remove a gate hook), `tf_list` (read-only inventory of tools / mcps / hooks), and
|
|
269
|
-
`tf_log` (toggle the JSONL audit log — see [`logging.md`](logging.md)). They write the same config
|
|
270
|
-
files through the same atomic API as everything above, and — being ordinary tools — run through
|
|
271
|
-
the **same PreToolUse gate**, so the management surface can itself be restricted (see
|
|
272
|
-
[`hooks-and-gating.md`](hooks-and-gating.md#gating-the-management-functions-recursive-safety)).
|
|
273
|
-
- **The optional admin UI** ([`admin-ui.md`](admin-ui.md)) — `node bin/toolfunnel.js --ui` serves a
|
|
274
|
-
local config web UI (`127.0.0.1:9777`) with Tools / MCPs / Hooks / Logs / Auth tabs for the same
|
|
275
|
-
add / activate / deactivate / remove operations, plus the per-tool execution mode, per-tool
|
|
276
|
-
Pre/Post hook toggles, and a per-tool Details / edit panel.
|
|
277
|
-
|
|
278
|
-
The liveness rules are unchanged: register and tool-state edits take effect with no reconnect
|
|
279
|
-
(§1.6), while curated-direct (`expose[]`) changes need a reconnect (§2.5).
|
|
280
|
-
|
|
281
|
-
---
|
|
282
|
-
|
|
283
|
-
## 2. Curating an upstream MCP
|
|
284
|
-
|
|
285
|
-
The second way to grow the surface is to **connect another MCP server** and selectively
|
|
286
|
-
re-expose some of its tools. The persisted config lives in one file, `mcp/expose.json`,
|
|
287
|
-
managed by `src/mcp/expose-store.js`; the live connections + curation are driven by
|
|
288
|
-
`src/mcp/aggregator.js`. A fully annotated working sample is in `mcp/expose.example.json`.
|
|
289
|
-
The default `mcp/expose.json` is **empty** (`{ "version":1, "upstreams":[], "expose":[] }`),
|
|
290
|
-
so the aggregator connects to nothing and the curated-direct surface is empty until you add
|
|
291
|
-
entries.
|
|
292
|
-
|
|
293
|
-
### 2.1 `expose.json` — two blocks
|
|
294
|
-
|
|
295
|
-
```json
|
|
296
|
-
{
|
|
297
|
-
"version": 1,
|
|
298
|
-
"upstreams": [
|
|
299
|
-
{
|
|
300
|
-
"id": "mock",
|
|
301
|
-
"transport": "stdio",
|
|
302
|
-
"command": "node",
|
|
303
|
-
"args": ["mcp/servers/mock-upstream/server.js"],
|
|
304
|
-
"env": {},
|
|
305
|
-
"enabled": true,
|
|
306
|
-
"description": "Bundled demo upstream MCP (zero-dependency stdio): ping, add, echo."
|
|
307
|
-
}
|
|
308
|
-
],
|
|
309
|
-
"expose": [
|
|
310
|
-
{ "upstream": "mock", "tool": "ping", "as": "mock_ping", "category": "demo", "enabled": true },
|
|
311
|
-
{ "upstream": "mock", "tool": "add", "as": "mock_add", "category": "demo", "enabled": true }
|
|
312
|
-
]
|
|
313
|
-
}
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
**`upstreams[]` — one entry per upstream MCP server:**
|
|
317
|
-
|
|
318
|
-
| Field | Type | Meaning |
|
|
319
|
-
|---|---|---|
|
|
320
|
-
| `id` | string (required, unique) | Stable key referenced by `expose[].upstream`. |
|
|
321
|
-
| `transport` | string | How to connect. `"stdio"` (spawn `command`+`args`) is the only baseline transport today. |
|
|
322
|
-
| `command` | string (required for stdio) | The interpreter/executable to spawn — `node`, `npx`, or an absolute path to a node binary. **Not** subject to the isolation guard (§2.3). |
|
|
323
|
-
| `args` | string[] | The argv. Any **path-shaped** token here (the server script, a db file, …) **must** resolve inside the gateway root — that's the isolation guard. |
|
|
324
|
-
| `env` | object | Extra environment for the spawned process. |
|
|
325
|
-
| `enabled` | boolean (default `true`) | Whether the aggregator connects to this upstream. |
|
|
326
|
-
| `description` | string | Human-facing note for the MCP Manager UI. |
|
|
327
|
-
|
|
328
|
-
**`expose[]` — which of the upstream's tools become curated-direct:**
|
|
329
|
-
|
|
330
|
-
| Field | Type | Meaning |
|
|
331
|
-
|---|---|---|
|
|
332
|
-
| `upstream` | string (required) | Must match an existing `upstreams[].id`. `addExpose` rejects an unknown upstream — add the upstream first. |
|
|
333
|
-
| `tool` | string (required) | The tool's **real** name as the upstream's `tools/list` reports it. `(upstream, tool)` is the natural key — unique. |
|
|
334
|
-
| `as` | string | The **downstream** name the agent + the gate see. Optional; defaults to `<upstream>_<tool>` (`exposedName()`), which namespaces it so two upstreams can't collide. `addExpose` stores the resolved name so the entry is self-describing. |
|
|
335
|
-
| `category` | string | Grouping label, same convention as register entries. |
|
|
336
|
-
| `enabled` | boolean (default `true`) | Whether this tool is actually surfaced right now. |
|
|
337
|
-
|
|
338
|
-
Like the register, the store validates on write and persists atomically; reads always
|
|
339
|
-
return clones and never throw (a missing `expose.json` is simply an empty store — the file
|
|
340
|
-
isn't created until the first write). Removing an upstream **cascade-removes** any `expose[]`
|
|
341
|
-
entries that referenced it, so you can never be left with a dangling exposure.
|
|
342
|
-
|
|
343
|
-
### 2.2 What the aggregator actually surfaces — and the `as` rename
|
|
344
|
-
|
|
345
|
-
`exposedToolDefinitions()` computes the curated-direct tool list the agent sees. An expose
|
|
346
|
-
entry surfaces **only if all three hold**:
|
|
347
|
-
|
|
348
|
-
1. the entry is `enabled: true`;
|
|
349
|
-
2. its upstream is **connected** (an enabled upstream, connected via `connectAll`); and
|
|
350
|
-
3. the connected upstream **actually advertises** a tool with that real `tool` name.
|
|
351
|
-
|
|
352
|
-
Anything that fails those is silently skipped — you can't expose a tool that isn't there.
|
|
353
|
-
For each surfaced entry the downstream definition is:
|
|
354
|
-
|
|
355
|
-
- **name** = the `as` name (e.g. `mock_ping`) — the renamed, namespaced name;
|
|
356
|
-
- **description** = `"[" + upstream + "] " + (upstream tool's description || "tool")`;
|
|
357
|
-
- **inputSchema** = the upstream tool's own `inputSchema` (or `{ type:"object" }`).
|
|
358
|
-
|
|
359
|
-
The rename is load-bearing in both directions. **Downstream** the agent calls the `as`
|
|
360
|
-
name; **upstream** the aggregator calls the tool's **real** name.
|
|
361
|
-
`resolveExposedExecution(name, args)` carries both: it returns an `execute()` thunk that
|
|
362
|
-
invokes the real upstream tool, plus `toolName` set to the **downstream `as` name** — so the
|
|
363
|
-
**PreToolUse gate matches on the downstream name** the agent used while execution hits the
|
|
364
|
-
real upstream tool. Curated-direct calls run through the **same `gatedRun`** path as
|
|
365
|
-
first-party tools (`server.js` threads the engine + ctx in) — the gate is uniform across
|
|
366
|
-
every execution path, by design and by test.
|
|
367
|
-
|
|
368
|
-
### 2.3 The isolation guard
|
|
369
|
-
|
|
370
|
-
This is the safety boundary that stops a curated upstream from reaching outside the gateway.
|
|
371
|
-
The default client factory (`aggregator.js::defaultClientFactory`) enforces it before
|
|
372
|
-
spawning anything:
|
|
373
|
-
|
|
374
|
-
> **Every `args` token that *looks like a path* must resolve inside the gateway root.**
|
|
375
|
-
|
|
376
|
-
- "Looks like a path" (`looksLikePath`) = an absolute path, **or** any token containing a
|
|
377
|
-
path separator (`/` or `\`). A bare token like `npx`, `server`, or a flag like `--port`
|
|
378
|
-
is **not** a path and is always allowed.
|
|
379
|
-
- "Inside the gateway root" (`isInside`) = resolves within the gateway root
|
|
380
|
-
(`path.resolve(__dirname, '..', '..')` from `src/mcp/`, i.e. `…/toolfunnel`). A
|
|
381
|
-
**relative** arg like `mcp/servers/mock-upstream/server.js` is resolved *against* the
|
|
382
|
-
root, so it's judged at its in-sandbox location and passes. An absolute path elsewhere, or
|
|
383
|
-
anything escaping via `..`, **fails**.
|
|
384
|
-
- The **`command` slot is deliberately NOT guarded.** It names the interpreter (`node`,
|
|
385
|
-
`npx`, an absolute path to a system node binary) which by definition lives outside the
|
|
386
|
-
project. Isolation is about **what code the spawned process runs** — that's the args (the
|
|
387
|
-
server script + any data files it loads) — not which binary launches it. Guarding the
|
|
388
|
-
command too would wrongly reject every vendored upstream the moment the system node ran it.
|
|
389
|
-
|
|
390
|
-
A violating arg throws an `isolation: …` error. `connectAll` never lets that sink the
|
|
391
|
-
server: the failure is collected into the returned `failed[]` (a dead or mis-scoped upstream
|
|
392
|
-
cannot crash startup, and never sinks the *other* upstreams). `discover()` — a direct user
|
|
393
|
-
action from the MCP Manager — does surface the error so the UI can show it, but still cleans
|
|
394
|
-
up so no zombie child is left.
|
|
395
|
-
|
|
396
|
-
**The practical rule:** vendor an upstream's server **inside the gateway tree** and point
|
|
397
|
-
`args` at the in-sandbox path. Never point `args` at a server configured elsewhere on the
|
|
398
|
-
host. If you want an external server's behaviour, clone it in first.
|
|
399
|
-
|
|
400
|
-
### 2.4 Steps to curate an upstream
|
|
401
|
-
|
|
402
|
-
1. Vendor the upstream server inside the gateway tree (so its script path stays in-sandbox).
|
|
403
|
-
2. Add an `upstreams[]` entry — unique `id`, `transport:"stdio"`, the `command`/`args`,
|
|
404
|
-
`enabled:true`. (`addUpstream` validates and persists.)
|
|
405
|
-
3. Let the aggregator connect (`connectAll`) or hit **Discover** in the MCP Manager
|
|
406
|
-
(`discover(id)` runs the upstream's `tools/list`) to see its real tool names + schemas.
|
|
407
|
-
4. For each tool you want hot, add an `expose[]` entry
|
|
408
|
-
`{ upstream, tool, as?, category, enabled:true }`. Give it an `as` name if the default
|
|
409
|
-
`<upstream>_<tool>` isn't what you want.
|
|
410
|
-
5. Leave the long tail **unexposed** — it stays connected but hidden, and is reachable later
|
|
411
|
-
simply by adding another `expose` entry.
|
|
412
|
-
|
|
413
|
-
### 2.5 Update cost — curated-direct vs. the register
|
|
414
|
-
|
|
415
|
-
The two layers refresh differently, and this drives the curation decision:
|
|
416
|
-
|
|
417
|
-
- **Curated-direct tools** (what `expose[]` produces) are negotiated with the client **at
|
|
418
|
-
connect time**, so changing the exposed set needs a **reconnect** (or a
|
|
419
|
-
`notifications/tools/list_changed` if the client honours it — the server advertises
|
|
420
|
-
`tools: { listChanged: true }`, but verify per host; restart is the safe baseline).
|
|
421
|
-
- **Register tools** (the `toolfunnel_list_tools` / `toolfunnel_run_tool` path) need **no
|
|
422
|
-
reconnect** — they ride the fixed meta-tool surface (§1.6), so the advertised tool list
|
|
423
|
-
never changes when you add or remove one.
|
|
424
|
-
|
|
425
|
-
Rule of thumb: expose a **handful of HOT, high-reliability tools** curated-direct; leave
|
|
426
|
-
everything else in the register, reached through the meta-tools.
|
|
427
|
-
|
|
428
|
-
---
|
|
429
|
-
|
|
430
|
-
## 3. Why lean? Briefs + schema-on-demand
|
|
431
|
-
|
|
432
|
-
The token-efficiency rationale is the whole reason both layers above are shaped the way they
|
|
433
|
-
are.
|
|
434
|
-
|
|
435
|
-
**The problem.** Vanilla MCP dumps **every** connected server's **full** tool schemas into
|
|
436
|
-
the model's context on **every** turn. Connect a few rich servers and you've burned
|
|
437
|
-
thousands of tokens describing tools the agent won't touch this turn — slower, costlier,
|
|
438
|
-
noisier, and it crowds out the context you actually want the model thinking about.
|
|
439
|
-
|
|
440
|
-
**The fix — a two-stage surface.** ToolFunnel advertises only a tiny fixed set of
|
|
441
|
-
**meta-tools** and keeps the real catalogue behind them:
|
|
442
|
-
|
|
443
|
-
1. `toolfunnel_list_tools({ filter?, category? })` returns **briefs only** —
|
|
444
|
-
`{ id, name, summary, category }` per tool. That's the entire per-tool cost the model
|
|
445
|
-
pays to *know a tool exists*. No `inputSchema`, no `instructions`. This is why §1.3 says
|
|
446
|
-
keep `summary` tight and discriminating: it's the one line the model reads to decide
|
|
447
|
-
whether to look closer. (`filter` is a case-insensitive substring over id/name/summary;
|
|
448
|
-
`category` is an exact match.)
|
|
449
|
-
2. `toolfunnel_tool_instructions({ id })` fetches the **full** instructions for **one** tool,
|
|
450
|
-
**on demand** — paid only when the model has decided it wants that specific tool.
|
|
451
|
-
3. `toolfunnel_run_tool({ id, args })` executes it (through the gate, §1.5).
|
|
452
|
-
4. `toolfunnel_howto({ topic })` serves the self-extension docs (the table at the top),
|
|
453
|
-
loaded only when the model is actually extending the gateway.
|
|
454
|
-
|
|
455
|
-
So the cost model flips from *"N tools × full schema, every turn"* to *"N tools × one-line
|
|
456
|
-
brief, plus the full schema for the one or two tools actually used."* For a catalogue of any
|
|
457
|
-
real size that's an order-of-magnitude reduction in resident tool tokens.
|
|
458
|
-
|
|
459
|
-
**Curated-direct is the deliberate exception.** A small number of HOT tools (§2) *are*
|
|
460
|
-
surfaced with full schemas at connect time, so the model can call them in one hop with no
|
|
461
|
-
`list`/`instructions` round-trip. That's the dial: directly-exposed = the hot path you pay
|
|
462
|
-
full schema for; the register meta-tools = the long tail you pay one line for until needed.
|
|
463
|
-
Curate sparingly and the resident surface stays small while the full catalogue stays one
|
|
464
|
-
call away.
|
|
465
|
-
|
|
466
|
-
**And it all stays gated.** Lean exposure changes *what the model sees*, never *what's
|
|
467
|
-
allowed to run* — both the register run-path and the curated-direct path funnel through the
|
|
468
|
-
same `gatedRun` PreToolUse gate (§1.5, §2.2), which fails closed. Token savings never come
|
|
469
|
-
at the cost of the safety invariant.
|