toolfunnel 0.3.0 → 0.5.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.
Files changed (40) hide show
  1. package/README.md +167 -324
  2. package/bin/toolfunnel.js +39 -4
  3. package/docs/KNOWN_BUGS.md +33 -0
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/packaging.md +115 -0
  6. package/package.json +3 -1
  7. package/src/auth/config.js +4 -2
  8. package/src/auth/resource-server.js +28 -3
  9. package/src/core/config-home.js +134 -0
  10. package/src/core/logger.js +4 -2
  11. package/src/core/requires.js +138 -0
  12. package/src/core/server-config.js +77 -0
  13. package/src/extend/create-tool.md +24 -4
  14. package/src/extend/package.md +33 -0
  15. package/src/mcp/aggregator.js +12 -7
  16. package/src/mcp/http-transport.js +71 -21
  17. package/src/mcp/mcp-client.js +9 -3
  18. package/src/mcp/server.js +181 -16
  19. package/src/packages/packager.js +5 -3
  20. package/src/tools/registry.js +47 -2
  21. package/src/ui/server.js +49 -3
  22. package/tools/scripts/tf-env.js +43 -0
  23. package/tools/scripts/tf-hook-add.js +4 -5
  24. package/tools/scripts/tf-hook-set.js +4 -5
  25. package/tools/scripts/tf-list.js +9 -12
  26. package/tools/scripts/tf-log.js +4 -1
  27. package/tools/scripts/tf-mcp-add.js +4 -6
  28. package/tools/scripts/tf-mcp-set.js +4 -6
  29. package/tools/scripts/tf-pack.js +237 -0
  30. package/tools/scripts/tf-tool-add.js +16 -16
  31. package/tools/scripts/tf-tool-set.js +5 -11
  32. package/tools/tools.register.json +46 -1
  33. package/docs/admin-ui.md +0 -404
  34. package/docs/configuration.md +0 -402
  35. package/docs/hooks-and-gating.md +0 -428
  36. package/docs/logging.md +0 -223
  37. package/docs/management.md +0 -429
  38. package/docs/oauth.md +0 -103
  39. package/docs/setup.md +0 -352
  40. 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.