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.
Files changed (78) hide show
  1. package/README.md +102 -213
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -225
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -981
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1322
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -511
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +112 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
  70. package/docs/admin-ui.md +0 -404
  71. package/docs/configuration.md +0 -402
  72. package/docs/hooks-and-gating.md +0 -428
  73. package/docs/logging.md +0 -223
  74. package/docs/management.md +0 -429
  75. package/docs/oauth.md +0 -103
  76. package/docs/releasing.md +0 -31
  77. package/docs/setup.md +0 -352
  78. package/docs/tools-and-upstreams.md +0 -469
@@ -1,466 +1,468 @@
1
- 'use strict';
2
-
3
- /**
4
- * protocol.js — the lean meta-tool protocol.
5
- *
6
- * This is the PURE dispatch core for the gateway's MCP server. It knows nothing about the
7
- * transport (no stdio, no SDK, no host, no filesystem) — every capability it needs
8
- * is INJECTED as a dependency so the whole thing unit-tests under `node:test` with plain
9
- * stub objects. `server.js` (the thin JSON-RPC 2.0 / stdio wrapper) is the only thing that
10
- * binds this to a transport.
11
- *
12
- * The model's tool surface is kept to exactly FOUR meta-tools:
13
- *
14
- * | Tool | Args | Returns |
15
- * |------------------------------|----------------------|--------------------------------------|
16
- * | toolfunnel_list_tools | { filter?, category?}| [{ name, summary, category }] |
17
- * | toolfunnel_tool_instructions | { name } | full usage/docs for one tool |
18
- * | toolfunnel_run_tool | { name, args } | tool result { ok, output, error? } |
19
- * | toolfunnel_howto | { topic } | self-extension instructions |
20
- *
21
- * The load-bearing one is `toolfunnel_run_tool`: it does **NOT** execute directly. It resolves
22
- * the tool's executor from the register, then hands it to `gatedRun`, which fires the host's
23
- * PreToolUse hooks (the gate — may DENY), executes ONLY if allowed, then fires PostToolUse
24
- * (PreToolUse blocks via `hookSpecificOutput.permissionDecision`). This module therefore NEVER
25
- * calls a tool's `execute` itself — it always goes through the gate. That invariant is the whole
26
- * safety case.
27
- *
28
- * Defensive by construction: an unknown meta-tool, a malformed argument, or a thrown
29
- * dependency all resolve to a clear *error result*. `dispatch()` never throws.
30
- *
31
- * Injected dependencies (makeProtocol({ ... })):
32
- * - registry : the dynamic tool register (src/tools/registry.js). Used:
33
- * registry.list({ filter, category }) -> [{ name, summary, category }]
34
- * registry.instructions(name) -> full docs (string or structured)
35
- * registry.resolveExecution(name, args) -> { execute, toolName?, args?, ... }
36
- * where `execute` is a () => any|Promise<any>
37
- * bound to run the actual tool.
38
- * - gatedRun : async ({ engine, ctx, toolName, args, execute }) -> { ok, output, error? }
39
- * fires PreToolUse (gate), runs `execute` iff allowed, fires PostToolUse.
40
- * - engine : the HookEngine (passed straight through to gatedRun; not used directly here).
41
- * - ctx : the session context object (session_id, cwd, transcript_path, …) passed to gatedRun.
42
- * - howto : (topic) -> self-extension instructions for create-tool|add-mcp|add-hook|package.
43
- *
44
- * CommonJS only. Node built-ins only. No transport. No SDK.
45
- */
46
-
47
- // Activity log. Self-gating (no-op unless explicitly enabled) and contracted never to
48
- // throw, so emit points cannot alter dispatch behaviour or break a tool run.
49
- const logger = require('../core/logger');
50
-
51
- /** The four meta-tool names — the entire exposed surface. */
52
- const META_TOOLS = Object.freeze({
53
- LIST: 'toolfunnel_list_tools',
54
- INSTRUCTIONS: 'toolfunnel_tool_instructions',
55
- RUN: 'toolfunnel_run_tool',
56
- HOWTO: 'toolfunnel_howto',
57
- });
58
-
59
- /** Valid `toolfunnel_howto` topics. */
60
- const HOWTO_TOPICS = Object.freeze(['create-tool', 'add-mcp', 'add-hook', 'package']);
61
-
62
- /**
63
- * Build a normalized error result. Meta-tool calls always resolve to a result object
64
- * (never throw), so the server can surface a clean message to the model instead of a
65
- * crashed JSON-RPC call.
66
- *
67
- * @param {string} message human/model-facing error text
68
- * @returns {{ ok: false, output: null, error: string }}
69
- */
70
- function errorResult(message) {
71
- return { ok: false, output: null, error: String(message) };
72
- }
73
-
74
- /**
75
- * Build a normalized success result.
76
- *
77
- * @param {*} output the payload to return to the model
78
- * @returns {{ ok: true, output: * }}
79
- */
80
- function okResult(output) {
81
- return { ok: true, output };
82
- }
83
-
84
- /**
85
- * Normalize whatever a dependency returns into the canonical { ok, output, error? } shape.
86
- * `gatedRun` is contracted to already return this shape — if it does, pass it through
87
- * untouched (preserving any extra fields like a block reason). Anything else is wrapped
88
- * as a success payload.
89
- *
90
- * @param {*} value
91
- * @returns {{ ok: boolean, output?: *, error?: string }}
92
- */
93
- function normalizeResult(value) {
94
- if (value && typeof value === 'object' && typeof value.ok === 'boolean') {
95
- return value; // already in canonical form (e.g. straight from gatedRun)
96
- }
97
- return okResult(value);
98
- }
99
-
100
- /**
101
- * makeProtocol — construct the meta-tool dispatcher.
102
- *
103
- * @param {object} deps
104
- * @param {object} deps.registry dynamic tool register (list/instructions/resolveExecution)
105
- * @param {(o: { engine: object, ctx: object, toolName: string, args: object,
106
- * execute: () => (any|Promise<any>) }) => Promise<{ok:boolean, output?:any, error?:string}>}
107
- * deps.gatedRun the hook-gated executor (PreToolUse -> execute -> PostToolUse)
108
- * @param {object} [deps.engine] the HookEngine, forwarded to gatedRun
109
- * @param {object} [deps.ctx] session context, forwarded to gatedRun
110
- * @param {(topic: string) => (string|object)} deps.howto self-extension instruction provider
111
- * @returns {{
112
- * META_TOOLS: object,
113
- * HOWTO_TOPICS: string[],
114
- * toolDefinitions: () => Array<{name:string, description:string, inputSchema:object}>,
115
- * dispatch: (toolName: string, args?: object) => Promise<{ok:boolean, output?:any, error?:string}>,
116
- * listTools: (args?: object) => Promise<object>,
117
- * toolInstructions: (args?: object) => Promise<object>,
118
- * runTool: (args?: object) => Promise<object>,
119
- * howtoTool: (args?: object) => Promise<object>
120
- * }}
121
- */
122
- function makeProtocol(deps) {
123
- const d = deps || {};
124
- const registry = d.registry;
125
- const gatedRun = d.gatedRun;
126
- const engine = d.engine;
127
- const ctx = d.ctx;
128
- const howto = d.howto;
129
-
130
- // ---- toolfunnel_list_tools ----------------------------------------------
131
- // Return the register, briefs only. Filtering/categorisation is the register's job
132
- // (it owns the data); we just forward the optional filter/category through.
133
- async function listTools(args) {
134
- if (!registry || typeof registry.list !== 'function') {
135
- return errorResult('toolfunnel_list_tools: registry.list is unavailable');
136
- }
137
- const a = args || {};
138
- const opts = {};
139
- if (a.filter !== undefined) opts.filter = a.filter;
140
- if (a.category !== undefined) opts.category = a.category;
141
- try {
142
- const list = await registry.list(opts);
143
- return okResult(list);
144
- } catch (err) {
145
- return errorResult(`toolfunnel_list_tools failed: ${(err && err.message) || err}`);
146
- }
147
- }
148
-
149
- // ---- toolfunnel_tool_instructions ---------------------------------------
150
- // Full usage/docs for ONE register tool, on demand (long-tail friendly).
151
- async function toolInstructions(args) {
152
- if (!registry || typeof registry.instructions !== 'function') {
153
- return errorResult('toolfunnel_tool_instructions: registry.instructions is unavailable');
154
- }
155
- const a = args || {};
156
- const name = a.name;
157
- if (typeof name !== 'string' || name.length === 0) {
158
- return errorResult('toolfunnel_tool_instructions: "name" (non-empty string) is required');
159
- }
160
- try {
161
- const docs = await registry.instructions(name);
162
- if (docs === undefined || docs === null) {
163
- return errorResult(`toolfunnel_tool_instructions: no tool named "${name}"`);
164
- }
165
- return okResult(docs);
166
- } catch (err) {
167
- return errorResult(`toolfunnel_tool_instructions failed for "${name}": ${(err && err.message) || err}`);
168
- }
169
- }
170
-
171
- // ---- toolfunnel_run_tool ------------------------------------------------
172
- // THE load-bearing meta-tool. Resolve the executor from the register, then run it
173
- // ONLY through gatedRun (PreToolUse gate -> execute -> PostToolUse). This function
174
- // must NEVER call `execute` itself — that would bypass the gate and break the entire
175
- // safety case.
176
- async function runTool(args) {
177
- const a = args || {};
178
- const name = a.name;
179
- if (typeof name !== 'string' || name.length === 0) {
180
- return errorResult('toolfunnel_run_tool: "name" (non-empty string) is required');
181
- }
182
- if (!registry || typeof registry.resolveExecution !== 'function') {
183
- return errorResult('toolfunnel_run_tool: registry.resolveExecution is unavailable');
184
- }
185
- if (typeof gatedRun !== 'function') {
186
- return errorResult('toolfunnel_run_tool: gatedRun is unavailable — refusing to execute ungated');
187
- }
188
-
189
- // The tool's input args (the model's payload for the underlying tool).
190
- const toolArgs = a.args !== undefined && a.args !== null ? a.args : {};
191
-
192
- // 1) Resolve how to execute this tool. The register turns (name, args) into a bound
193
- // executor. A null/undefined resolution means "no such tool" — a clean error, not a throw.
194
- let resolution;
195
- try {
196
- resolution = await registry.resolveExecution(name, toolArgs);
197
- } catch (err) {
198
- return errorResult(`toolfunnel_run_tool: could not resolve "${name}": ${(err && err.message) || err}`);
199
- }
200
-
201
- // 1a) REFERENCE mode. A reference tool executes NOTHING here — the connected AI performs the
202
- // action in its OWN environment per the instructions, so we NEVER spawn and NEVER call
203
- // gatedRun. The HANDOFF itself, however, is gated: reference fires PreToolUse ADVISORILY —
204
- // a deny gates the instructions HANDOFF, not the AI's own-environment execution. A
205
- // PreToolUse deny therefore REFUSES to hand over the instructions (a handoff-gate); allow /
206
- // no-engine / no-ctx hands them over exactly as before. Nothing runs here in EITHER case.
207
- // Gateway tools fall through to the gate below, byte-for-byte unchanged.
208
- if (resolution && resolution.mode === 'reference') {
209
- const refInstructions =
210
- typeof resolution.instructions === 'string' ? resolution.instructions : '';
211
- // The gate name is the SAME one the gateway path uses for this tool, so a PreToolUse matcher
212
- // sees a stable tool_name regardless of mode.
213
- const gateName = typeof resolution.toolName === 'string' ? resolution.toolName : name;
214
-
215
- // The unconditional handoff — also the SAFE fallback when there is no engine/ctx to gate with.
216
- const handoff = (additionalContext) => {
217
- logger.log({ type: 'tool', name, mode: 'reference', ok: true, blocked: false });
218
- const out = {
219
- ok: true,
220
- mode: 'reference',
221
- name,
222
- instructions: refInstructions,
223
- message: 'reference tool — perform this in your own environment per the instructions',
224
- };
225
- if (typeof additionalContext === 'string' && additionalContext.length > 0) {
226
- out.additionalContext = additionalContext;
227
- }
228
- return out;
229
- };
230
-
231
- // No engine / no ctx → cannot gate; hand the instructions over exactly as before.
232
- if (!engine || typeof engine.fire !== 'function' || !ctx) {
233
- return handoff();
234
- }
235
-
236
- // Fire PreToolUse the same way gatedRun does: engine.fire('PreToolUse', ctx,
237
- // { tool_name, tool_input }). The engine has already normalised any deny into
238
- // { blocked, reason } and any injected additionalContext into { injected }.
239
- let pre = null;
240
- try {
241
- pre = await engine.fire('PreToolUse', ctx, { tool_name: gateName, tool_input: toolArgs });
242
- } catch (_err) {
243
- // A misbehaving engine must not break the advisory handoff — fall back to handing over.
244
- pre = null;
245
- }
246
-
247
- if (pre && pre.blocked === true) {
248
- // DENY → refuse the HANDOFF. No instructions, no spawn.
249
- logger.log({ type: 'tool', name, mode: 'reference', ok: false, blocked: true });
250
- return {
251
- ok: false,
252
- blocked: true,
253
- mode: 'reference',
254
- name,
255
- reason: pre.reason != null ? pre.reason : 'blocked by PreToolUse',
256
- };
257
- }
258
-
259
- // Allowed → hand over, carrying any hook-injected additionalContext.
260
- return handoff(pre && typeof pre.injected === 'string' ? pre.injected : '');
261
- }
262
-
263
- if (!resolution || typeof resolution.execute !== 'function') {
264
- return errorResult(
265
- `toolfunnel_run_tool: tool "${name}" is not runnable (no executable resolution from the register)`
266
- );
267
- }
268
-
269
- // Prefer the register's canonical toolName/args (it may normalize them); fall back to
270
- // what the caller gave. The toolName is what PreToolUse matchers see — it must be stable.
271
- const toolName = typeof resolution.toolName === 'string' ? resolution.toolName : name;
272
- const execArgs = resolution.args !== undefined ? resolution.args : toolArgs;
273
-
274
- // The execution mode for logging — the resolution's own mode if it set one, else
275
- // the default gateway path.
276
- const mode = typeof resolution.mode === 'string' ? resolution.mode : 'gateway';
277
-
278
- // 2) Execute ONLY through the gate. gatedRun fires PreToolUse (may deny), runs execute
279
- // iff allowed, then fires PostToolUse, and returns the canonical { ok, output, error? }.
280
- const startedAt = Date.now();
281
- try {
282
- const result = await gatedRun({
283
- engine,
284
- ctx,
285
- toolName,
286
- args: execArgs,
287
- execute: resolution.execute,
288
- });
289
- const normalized = normalizeResult(result);
290
- // Activity log: record the run outcome. Self-gates on enabled; never throws.
291
- logger.log({
292
- type: 'tool',
293
- name,
294
- mode,
295
- ok: normalized.ok === true,
296
- blocked: normalized.blocked === true,
297
- durationMs: Date.now() - startedAt,
298
- });
299
- return normalized;
300
- } catch (err) {
301
- // gatedRun is contracted not to throw, but if it does we still return a clean result.
302
- logger.log({
303
- type: 'tool',
304
- name,
305
- mode,
306
- ok: false,
307
- blocked: false,
308
- durationMs: Date.now() - startedAt,
309
- });
310
- return errorResult(`toolfunnel_run_tool: gated execution of "${name}" failed: ${(err && err.message) || err}`);
311
- }
312
- }
313
-
314
- // ---- toolfunnel_howto ---------------------------------------------------
315
- // Self-extension instructions for a known topic (makes the system self-extending).
316
- async function howtoTool(args) {
317
- if (typeof howto !== 'function') {
318
- return errorResult('toolfunnel_howto: howto provider is unavailable');
319
- }
320
- const a = args || {};
321
- const topic = a.topic;
322
- if (typeof topic !== 'string' || topic.length === 0) {
323
- return errorResult(
324
- `toolfunnel_howto: "topic" is required (one of ${HOWTO_TOPICS.join(' | ')})`
325
- );
326
- }
327
- if (!HOWTO_TOPICS.includes(topic)) {
328
- return errorResult(
329
- `toolfunnel_howto: unknown topic "${topic}" (expected one of ${HOWTO_TOPICS.join(' | ')})`
330
- );
331
- }
332
- try {
333
- const content = await howto(topic);
334
- if (content === undefined || content === null) {
335
- return errorResult(`toolfunnel_howto: no instructions available for topic "${topic}"`);
336
- }
337
- return okResult(content);
338
- } catch (err) {
339
- return errorResult(`toolfunnel_howto failed for "${topic}": ${(err && err.message) || err}`);
340
- }
341
- }
342
-
343
- // ---- dispatch -----------------------------------------------------------
344
- // The server's single entry point. Pure routing; never throws — an unknown tool or any
345
- // internal failure becomes a clear error result (defensive by construction).
346
- async function dispatch(toolName, args) {
347
- try {
348
- switch (toolName) {
349
- case META_TOOLS.LIST:
350
- return await listTools(args);
351
- case META_TOOLS.INSTRUCTIONS:
352
- return await toolInstructions(args);
353
- case META_TOOLS.RUN:
354
- return await runTool(args);
355
- case META_TOOLS.HOWTO:
356
- return await howtoTool(args);
357
- default: {
358
- const known = Object.values(META_TOOLS).join(', ');
359
- return errorResult(
360
- `Unknown meta-tool "${toolName}". This MCP exposes only: ${known}.`
361
- );
362
- }
363
- }
364
- } catch (err) {
365
- // Last line of defence: dispatch itself must never throw.
366
- return errorResult(
367
- `dispatch("${toolName}") failed unexpectedly: ${(err && err.message) || err}`
368
- );
369
- }
370
- }
371
-
372
- // ---- toolDefinitions ----------------------------------------------------
373
- // The JSON-Schema input shapes for the ADVERTISED meta-tools, for `tools/list`.
374
- // All FOUR are advertised: list, instructions, run, howto. toolfunnel_run_tool executes
375
- // a register tool ONLY through gatedRun (PreToolUse gate -> execute -> PostToolUse), so a
376
- // connected agent can run tools while every call still passes through the gate — the gate,
377
- // not the absence of the meta-tool, is the safety boundary.
378
- function toolDefinitions() {
379
- return [
380
- {
381
- name: META_TOOLS.LIST,
382
- description:
383
- 'List the available tools (briefs only). Optionally filter by a free-text ' +
384
- 'string and/or a category. Returns [{ name, summary, category }]. ' +
385
- 'Call toolfunnel_tool_instructions for a specific tool, then run it yourself per its instructions.',
386
- inputSchema: {
387
- type: 'object',
388
- properties: {
389
- filter: {
390
- type: 'string',
391
- description: 'Optional free-text filter matched against tool name/summary.',
392
- },
393
- category: {
394
- type: 'string',
395
- description: 'Optional category to restrict the listing to.',
396
- },
397
- },
398
- additionalProperties: false,
399
- },
400
- },
401
- {
402
- name: META_TOOLS.INSTRUCTIONS,
403
- description:
404
- 'Get the full usage instructions / documentation for one tool by name (venv, the ' +
405
- 'reliable invoke command, safety notes). Read this before running the tool yourself.',
406
- inputSchema: {
407
- type: 'object',
408
- properties: {
409
- name: {
410
- type: 'string',
411
- description: 'The exact tool name (as returned by toolfunnel_list_tools).',
412
- },
413
- },
414
- required: ['name'],
415
- additionalProperties: false,
416
- },
417
- },
418
- {
419
- name: META_TOOLS.RUN,
420
- description: 'Execute one register tool by its name through the gateway PreToolUse gate. Args: { name, args? }. Call toolfunnel_tool_instructions first to learn the tool\'s arg shape. Returns { ok, output, error? }. Every call is gated (a PreToolUse hook may deny it).',
421
- inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Exact tool name from toolfunnel_list_tools.' }, args: { type: 'object', description: 'Structured arguments forwarded to the tool. Optional.' } }, required: ['name'], additionalProperties: false },
422
- },
423
- {
424
- name: META_TOOLS.HOWTO,
425
- description:
426
- 'Get self-extension instructions: how to author a new tool, register an upstream ' +
427
- 'MCP, add a hook, or build a shareable package. The system documents how to extend itself.',
428
- inputSchema: {
429
- type: 'object',
430
- properties: {
431
- topic: {
432
- type: 'string',
433
- enum: HOWTO_TOPICS.slice(),
434
- description:
435
- 'Which self-extension guide to return: create-tool | add-mcp | add-hook | package.',
436
- },
437
- },
438
- required: ['topic'],
439
- additionalProperties: false,
440
- },
441
- },
442
- ];
443
- }
444
-
445
- return {
446
- META_TOOLS,
447
- HOWTO_TOPICS,
448
- toolDefinitions,
449
- dispatch,
450
- // Individual handlers exported for direct unit testing / reuse.
451
- listTools,
452
- toolInstructions,
453
- runTool,
454
- howtoTool,
455
- };
456
- }
457
-
458
- module.exports = {
459
- makeProtocol,
460
- META_TOOLS,
461
- HOWTO_TOPICS,
462
- // Exported for tests / reuse:
463
- errorResult,
464
- okResult,
465
- normalizeResult,
466
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * protocol.js - the lean meta-tool protocol.
5
+ *
6
+ * This is the PURE dispatch core for the gateway's MCP server. It knows nothing about the
7
+ * transport (no stdio, no SDK, no host, no filesystem) - every capability it needs
8
+ * is INJECTED as a dependency so the whole thing unit-tests under `node:test` with plain
9
+ * stub objects. `server.js` (the thin JSON-RPC 2.0 / stdio wrapper) is the only thing that
10
+ * binds this to a transport.
11
+ *
12
+ * The model's tool surface is kept to exactly FOUR meta-tools:
13
+ *
14
+ * | Tool | Args | Returns |
15
+ * |------------------------------|----------------------|--------------------------------------|
16
+ * | toolfunnel_list_tools | { filter?, category?}| [{ name, summary, category }] |
17
+ * | toolfunnel_tool_instructions | { name } | full usage/docs for one tool |
18
+ * | toolfunnel_run_tool | { name, args } | tool result { ok, output, error? } |
19
+ * | toolfunnel_howto | { topic } | self-extension instructions |
20
+ *
21
+ * The load-bearing one is `toolfunnel_run_tool`: it does **NOT** execute directly. It resolves
22
+ * the tool's executor from the register, then hands it to `gatedRun`, which fires the host's
23
+ * PreToolUse hooks (the gate - may DENY), executes ONLY if allowed, then fires PostToolUse
24
+ * (PreToolUse blocks via `hookSpecificOutput.permissionDecision`). This module therefore NEVER
25
+ * calls a tool's `execute` itself - it always goes through the gate. That invariant is the whole
26
+ * safety case.
27
+ *
28
+ * Defensive by construction: an unknown meta-tool, a malformed argument, or a thrown
29
+ * dependency all resolve to a clear *error result*. `dispatch()` never throws.
30
+ *
31
+ * Injected dependencies (makeProtocol({ ... })):
32
+ * - registry : the dynamic tool register (src/tools/registry.js). Used:
33
+ * registry.list({ filter, category }) -> [{ name, summary, category }]
34
+ * registry.instructions(name) -> full docs (string or structured)
35
+ * registry.resolveExecution(name, args) -> { execute, toolName?, args?, ... }
36
+ * where `execute` is a () => any|Promise<any>
37
+ * bound to run the actual tool.
38
+ * - gatedRun : async ({ engine, ctx, toolName, args, execute }) -> { ok, output, error? }
39
+ * fires PreToolUse (gate), runs `execute` iff allowed, fires PostToolUse.
40
+ * - engine : the HookEngine (passed straight through to gatedRun; not used directly here).
41
+ * - ctx : the session context object (session_id, cwd, transcript_path, ...) passed to gatedRun.
42
+ * - howto : (topic) -> self-extension instructions for create-tool|add-mcp|add-hook|package.
43
+ *
44
+ * CommonJS only. Node built-ins only. No transport. No SDK.
45
+ */
46
+
47
+ // Activity log. Self-gating (no-op unless explicitly enabled) and contracted never to
48
+ // throw, so emit points cannot alter dispatch behaviour or break a tool run.
49
+ const logger = require('../core/logger');
50
+
51
+ /** The four meta-tool names - the entire exposed surface. */
52
+ const META_TOOLS = Object.freeze({
53
+ LIST: 'toolfunnel_list_tools',
54
+ INSTRUCTIONS: 'toolfunnel_tool_instructions',
55
+ RUN: 'toolfunnel_run_tool',
56
+ HOWTO: 'toolfunnel_howto',
57
+ });
58
+
59
+ /** Valid `toolfunnel_howto` topics. */
60
+ const HOWTO_TOPICS = Object.freeze(['create-tool', 'add-mcp', 'add-hook', 'package', 'wrap', 'configure']);
61
+
62
+ /**
63
+ * Build a normalized error result. Meta-tool calls always resolve to a result object
64
+ * (never throw), so the server can surface a clean message to the model instead of a
65
+ * crashed JSON-RPC call.
66
+ *
67
+ * @param {string} message human/model-facing error text
68
+ * @returns {{ ok: false, output: null, error: string }}
69
+ */
70
+ function errorResult(message) {
71
+ return { ok: false, output: null, error: String(message) };
72
+ }
73
+
74
+ /**
75
+ * Build a normalized success result.
76
+ *
77
+ * @param {*} output the payload to return to the model
78
+ * @returns {{ ok: true, output: * }}
79
+ */
80
+ function okResult(output) {
81
+ return { ok: true, output };
82
+ }
83
+
84
+ /**
85
+ * Normalize whatever a dependency returns into the canonical { ok, output, error? } shape.
86
+ * `gatedRun` is contracted to already return this shape - if it does, pass it through
87
+ * untouched (preserving any extra fields like a block reason). Anything else is wrapped
88
+ * as a success payload.
89
+ *
90
+ * @param {*} value
91
+ * @returns {{ ok: boolean, output?: *, error?: string }}
92
+ */
93
+ function normalizeResult(value) {
94
+ if (value && typeof value === 'object' && typeof value.ok === 'boolean') {
95
+ return value; // already in canonical form (e.g. straight from gatedRun)
96
+ }
97
+ return okResult(value);
98
+ }
99
+
100
+ /**
101
+ * makeProtocol - construct the meta-tool dispatcher.
102
+ *
103
+ * @param {object} deps
104
+ * @param {object} deps.registry dynamic tool register (list/instructions/resolveExecution)
105
+ * @param {(o: { engine: object, ctx: object, toolName: string, args: object,
106
+ * execute: () => (any|Promise<any>) }) => Promise<{ok:boolean, output?:any, error?:string}>}
107
+ * deps.gatedRun the hook-gated executor (PreToolUse -> execute -> PostToolUse)
108
+ * @param {object} [deps.engine] the HookEngine, forwarded to gatedRun
109
+ * @param {object} [deps.ctx] session context, forwarded to gatedRun
110
+ * @param {(topic: string) => (string|object)} deps.howto self-extension instruction provider
111
+ * @returns {{
112
+ * META_TOOLS: object,
113
+ * HOWTO_TOPICS: string[],
114
+ * toolDefinitions: () => Array<{name:string, description:string, inputSchema:object}>,
115
+ * dispatch: (toolName: string, args?: object) => Promise<{ok:boolean, output?:any, error?:string}>,
116
+ * listTools: (args?: object) => Promise<object>,
117
+ * toolInstructions: (args?: object) => Promise<object>,
118
+ * runTool: (args?: object) => Promise<object>,
119
+ * howtoTool: (args?: object) => Promise<object>
120
+ * }}
121
+ */
122
+ function makeProtocol(deps) {
123
+ const d = deps || {};
124
+ const registry = d.registry;
125
+ const gatedRun = d.gatedRun;
126
+ const engine = d.engine;
127
+ const ctx = d.ctx;
128
+ const howto = d.howto;
129
+
130
+ // ---- toolfunnel_list_tools ----------------------------------------------
131
+ // Return the register, briefs only. Filtering/categorisation is the register's job
132
+ // (it owns the data); we just forward the optional filter/category through.
133
+ async function listTools(args) {
134
+ if (!registry || typeof registry.list !== 'function') {
135
+ return errorResult('toolfunnel_list_tools: registry.list is unavailable');
136
+ }
137
+ const a = args || {};
138
+ const opts = {};
139
+ if (a.filter !== undefined) opts.filter = a.filter;
140
+ if (a.category !== undefined) opts.category = a.category;
141
+ try {
142
+ const list = await registry.list(opts);
143
+ return okResult(list);
144
+ } catch (err) {
145
+ return errorResult(`toolfunnel_list_tools failed: ${(err && err.message) || err}`);
146
+ }
147
+ }
148
+
149
+ // ---- toolfunnel_tool_instructions ---------------------------------------
150
+ // Full usage/docs for ONE register tool, on demand (long-tail friendly).
151
+ async function toolInstructions(args) {
152
+ if (!registry || typeof registry.instructions !== 'function') {
153
+ return errorResult('toolfunnel_tool_instructions: registry.instructions is unavailable');
154
+ }
155
+ const a = args || {};
156
+ const name = a.name;
157
+ if (typeof name !== 'string' || name.length === 0) {
158
+ return errorResult('toolfunnel_tool_instructions: "name" (non-empty string) is required');
159
+ }
160
+ try {
161
+ const docs = await registry.instructions(name);
162
+ if (docs === undefined || docs === null) {
163
+ return errorResult(`toolfunnel_tool_instructions: no tool named "${name}"`);
164
+ }
165
+ return okResult(docs);
166
+ } catch (err) {
167
+ return errorResult(`toolfunnel_tool_instructions failed for "${name}": ${(err && err.message) || err}`);
168
+ }
169
+ }
170
+
171
+ // ---- toolfunnel_run_tool ------------------------------------------------
172
+ // THE load-bearing meta-tool. Resolve the executor from the register, then run it
173
+ // ONLY through gatedRun (PreToolUse gate -> execute -> PostToolUse). This function
174
+ // must NEVER call `execute` itself - that would bypass the gate and break the entire
175
+ // safety case.
176
+ async function runTool(args) {
177
+ const a = args || {};
178
+ const name = a.name;
179
+ if (typeof name !== 'string' || name.length === 0) {
180
+ return errorResult('toolfunnel_run_tool: "name" (non-empty string) is required');
181
+ }
182
+ if (!registry || typeof registry.resolveExecution !== 'function') {
183
+ return errorResult('toolfunnel_run_tool: registry.resolveExecution is unavailable');
184
+ }
185
+ if (typeof gatedRun !== 'function') {
186
+ return errorResult('toolfunnel_run_tool: gatedRun is unavailable - refusing to execute ungated');
187
+ }
188
+
189
+ // The tool's input args (the model's payload for the underlying tool).
190
+ const toolArgs = a.args !== undefined && a.args !== null ? a.args : {};
191
+
192
+ // 1) Resolve how to execute this tool. The register turns (name, args) into a bound
193
+ // executor. A null/undefined resolution means "no such tool" - a clean error, not a throw.
194
+ let resolution;
195
+ try {
196
+ resolution = await registry.resolveExecution(name, toolArgs);
197
+ } catch (err) {
198
+ return errorResult(`toolfunnel_run_tool: could not resolve "${name}": ${(err && err.message) || err}`);
199
+ }
200
+
201
+ // 1a) REFERENCE mode. A reference tool executes NOTHING here - the connected AI performs the
202
+ // action in its OWN environment per the instructions, so we NEVER spawn and NEVER call
203
+ // gatedRun. The HANDOFF itself, however, is gated: reference fires PreToolUse ADVISORILY -
204
+ // a deny gates the instructions HANDOFF, not the AI's own-environment execution. A
205
+ // PreToolUse deny therefore REFUSES to hand over the instructions (a handoff-gate); allow /
206
+ // no-engine / no-ctx hands them over exactly as before. Nothing runs here in EITHER case.
207
+ // Gateway tools fall through to the gate below, byte-for-byte unchanged.
208
+ if (resolution && resolution.mode === 'reference') {
209
+ const refInstructions =
210
+ typeof resolution.instructions === 'string' ? resolution.instructions : '';
211
+ // The gate name is the SAME one the gateway path uses for this tool, so a PreToolUse matcher
212
+ // sees a stable tool_name regardless of mode.
213
+ const gateName = typeof resolution.toolName === 'string' ? resolution.toolName : name;
214
+
215
+ // The unconditional handoff - also the SAFE fallback when there is no engine/ctx to gate with.
216
+ const handoff = (additionalContext) => {
217
+ logger.log({ type: 'tool', name, mode: 'reference', ok: true, blocked: false });
218
+ const out = {
219
+ ok: true,
220
+ mode: 'reference',
221
+ name,
222
+ instructions: refInstructions,
223
+ message: 'reference tool - perform this in your own environment per the instructions',
224
+ };
225
+ if (typeof additionalContext === 'string' && additionalContext.length > 0) {
226
+ out.additionalContext = additionalContext;
227
+ }
228
+ return out;
229
+ };
230
+
231
+ // No engine / no ctx -> cannot gate; hand the instructions over exactly as before.
232
+ if (!engine || typeof engine.fire !== 'function' || !ctx) {
233
+ return handoff();
234
+ }
235
+
236
+ // Fire PreToolUse the same way gatedRun does: engine.fire('PreToolUse', ctx,
237
+ // { tool_name, tool_input }). The engine has already normalised any deny into
238
+ // { blocked, reason } and any injected additionalContext into { injected }.
239
+ let pre = null;
240
+ try {
241
+ pre = await engine.fire('PreToolUse', ctx, { tool_name: gateName, tool_input: toolArgs });
242
+ } catch (_err) {
243
+ // A misbehaving engine must not break the advisory handoff - fall back to handing over.
244
+ pre = null;
245
+ }
246
+
247
+ if (pre && pre.blocked === true) {
248
+ // DENY -> refuse the HANDOFF. No instructions, no spawn.
249
+ logger.log({ type: 'tool', name, mode: 'reference', ok: false, blocked: true });
250
+ return {
251
+ ok: false,
252
+ blocked: true,
253
+ mode: 'reference',
254
+ name,
255
+ reason: pre.reason != null ? pre.reason : 'blocked by PreToolUse',
256
+ };
257
+ }
258
+
259
+ // Allowed -> hand over, carrying any hook-injected additionalContext.
260
+ return handoff(pre && typeof pre.injected === 'string' ? pre.injected : '');
261
+ }
262
+
263
+ if (!resolution || typeof resolution.execute !== 'function') {
264
+ return errorResult(
265
+ `toolfunnel_run_tool: tool "${name}" is not runnable (no executable resolution from the register)`
266
+ );
267
+ }
268
+
269
+ // Prefer the register's canonical toolName/args (it may normalize them); fall back to
270
+ // what the caller gave. The toolName is what PreToolUse matchers see - it must be stable.
271
+ const toolName = typeof resolution.toolName === 'string' ? resolution.toolName : name;
272
+ const execArgs = resolution.args !== undefined ? resolution.args : toolArgs;
273
+
274
+ // The execution mode for logging - the resolution's own mode if it set one, else
275
+ // the default gateway path.
276
+ const mode = typeof resolution.mode === 'string' ? resolution.mode : 'gateway';
277
+
278
+ // 2) Execute ONLY through the gate. gatedRun fires PreToolUse (may deny), runs execute
279
+ // iff allowed, then fires PostToolUse, and returns the canonical { ok, output, error? }.
280
+ const startedAt = Date.now();
281
+ try {
282
+ const result = await gatedRun({
283
+ engine,
284
+ ctx,
285
+ toolName,
286
+ args: execArgs,
287
+ execute: resolution.execute,
288
+ });
289
+ const normalized = normalizeResult(result);
290
+ // Activity log: record the run outcome. Self-gates on enabled; never throws.
291
+ logger.log({
292
+ type: 'tool',
293
+ name,
294
+ mode,
295
+ ok: normalized.ok === true,
296
+ blocked: normalized.blocked === true,
297
+ durationMs: Date.now() - startedAt,
298
+ });
299
+ return normalized;
300
+ } catch (err) {
301
+ // gatedRun is contracted not to throw, but if it does we still return a clean result.
302
+ logger.log({
303
+ type: 'tool',
304
+ name,
305
+ mode,
306
+ ok: false,
307
+ blocked: false,
308
+ durationMs: Date.now() - startedAt,
309
+ });
310
+ return errorResult(`toolfunnel_run_tool: gated execution of "${name}" failed: ${(err && err.message) || err}`);
311
+ }
312
+ }
313
+
314
+ // ---- toolfunnel_howto ---------------------------------------------------
315
+ // Self-extension instructions for a known topic (makes the system self-extending).
316
+ async function howtoTool(args) {
317
+ if (typeof howto !== 'function') {
318
+ return errorResult('toolfunnel_howto: howto provider is unavailable');
319
+ }
320
+ const a = args || {};
321
+ const topic = a.topic;
322
+ if (typeof topic !== 'string' || topic.length === 0) {
323
+ return errorResult(
324
+ `toolfunnel_howto: "topic" is required (one of ${HOWTO_TOPICS.join(' | ')})`
325
+ );
326
+ }
327
+ if (!HOWTO_TOPICS.includes(topic)) {
328
+ return errorResult(
329
+ `toolfunnel_howto: unknown topic "${topic}" (expected one of ${HOWTO_TOPICS.join(' | ')})`
330
+ );
331
+ }
332
+ try {
333
+ const content = await howto(topic);
334
+ if (content === undefined || content === null) {
335
+ return errorResult(`toolfunnel_howto: no instructions available for topic "${topic}"`);
336
+ }
337
+ return okResult(content);
338
+ } catch (err) {
339
+ return errorResult(`toolfunnel_howto failed for "${topic}": ${(err && err.message) || err}`);
340
+ }
341
+ }
342
+
343
+ // ---- dispatch -----------------------------------------------------------
344
+ // The server's single entry point. Pure routing; never throws - an unknown tool or any
345
+ // internal failure becomes a clear error result (defensive by construction).
346
+ async function dispatch(toolName, args) {
347
+ try {
348
+ switch (toolName) {
349
+ case META_TOOLS.LIST:
350
+ return await listTools(args);
351
+ case META_TOOLS.INSTRUCTIONS:
352
+ return await toolInstructions(args);
353
+ case META_TOOLS.RUN:
354
+ return await runTool(args);
355
+ case META_TOOLS.HOWTO:
356
+ return await howtoTool(args);
357
+ default: {
358
+ const known = Object.values(META_TOOLS).join(', ');
359
+ return errorResult(
360
+ `Unknown meta-tool "${toolName}". This MCP exposes only: ${known}.`
361
+ );
362
+ }
363
+ }
364
+ } catch (err) {
365
+ // Last line of defence: dispatch itself must never throw.
366
+ return errorResult(
367
+ `dispatch("${toolName}") failed unexpectedly: ${(err && err.message) || err}`
368
+ );
369
+ }
370
+ }
371
+
372
+ // ---- toolDefinitions ----------------------------------------------------
373
+ // The JSON-Schema input shapes for the ADVERTISED meta-tools, for `tools/list`.
374
+ // All FOUR are advertised: list, instructions, run, howto. toolfunnel_run_tool executes
375
+ // a register tool ONLY through gatedRun (PreToolUse gate -> execute -> PostToolUse), so a
376
+ // connected agent can run tools while every call still passes through the gate - the gate,
377
+ // not the absence of the meta-tool, is the safety boundary.
378
+ function toolDefinitions() {
379
+ return [
380
+ {
381
+ name: META_TOOLS.LIST,
382
+ description:
383
+ 'List the available tools (briefs only). Optionally filter by a free-text ' +
384
+ 'string and/or a category. Returns [{ name, summary, category }]. ' +
385
+ 'Call toolfunnel_tool_instructions for a specific tool, then run it yourself per its instructions.',
386
+ inputSchema: {
387
+ type: 'object',
388
+ properties: {
389
+ filter: {
390
+ type: 'string',
391
+ description: 'Optional free-text filter matched against tool name/summary.',
392
+ },
393
+ category: {
394
+ type: 'string',
395
+ description: 'Optional category to restrict the listing to.',
396
+ },
397
+ },
398
+ additionalProperties: false,
399
+ },
400
+ },
401
+ {
402
+ name: META_TOOLS.INSTRUCTIONS,
403
+ description:
404
+ 'Get the full usage instructions / documentation for one tool by name (venv, the ' +
405
+ 'reliable invoke command, safety notes). Read this before running the tool yourself.',
406
+ inputSchema: {
407
+ type: 'object',
408
+ properties: {
409
+ name: {
410
+ type: 'string',
411
+ description: 'The exact tool name (as returned by toolfunnel_list_tools).',
412
+ },
413
+ },
414
+ required: ['name'],
415
+ additionalProperties: false,
416
+ },
417
+ },
418
+ {
419
+ name: META_TOOLS.RUN,
420
+ description: 'Execute one register tool by its name through the gateway PreToolUse gate. Args: { name, args? }. Call toolfunnel_tool_instructions first to learn the tool\'s arg shape. Returns { ok, output, error? }. Every call is gated (a PreToolUse hook may deny it).',
421
+ inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Exact tool name from toolfunnel_list_tools.' }, args: { type: 'object', description: 'Structured arguments forwarded to the tool. Optional.' } }, required: ['name'], additionalProperties: false },
422
+ },
423
+ {
424
+ name: META_TOOLS.HOWTO,
425
+ description:
426
+ 'Get self-extension instructions: how to author a new tool, register an upstream ' +
427
+ 'MCP, add a hook, build a shareable package, WRAP one MCP server as the entire ' +
428
+ 'surface (transparent passthrough), or configure the gateway with JSON files only. ' +
429
+ 'The system documents how to extend itself.',
430
+ inputSchema: {
431
+ type: 'object',
432
+ properties: {
433
+ topic: {
434
+ type: 'string',
435
+ enum: HOWTO_TOPICS.slice(),
436
+ description:
437
+ 'Which guide to return: create-tool | add-mcp | add-hook | package | wrap | configure.',
438
+ },
439
+ },
440
+ required: ['topic'],
441
+ additionalProperties: false,
442
+ },
443
+ },
444
+ ];
445
+ }
446
+
447
+ return {
448
+ META_TOOLS,
449
+ HOWTO_TOPICS,
450
+ toolDefinitions,
451
+ dispatch,
452
+ // Individual handlers exported for direct unit testing / reuse.
453
+ listTools,
454
+ toolInstructions,
455
+ runTool,
456
+ howtoTool,
457
+ };
458
+ }
459
+
460
+ module.exports = {
461
+ makeProtocol,
462
+ META_TOOLS,
463
+ HOWTO_TOPICS,
464
+ // Exported for tests / reuse:
465
+ errorResult,
466
+ okResult,
467
+ normalizeResult,
468
+ };