toolfunnel 0.6.0 → 0.7.1

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 (72) hide show
  1. package/README.md +228 -231
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -372
  4. package/docs/KNOWN_BUGS.md +80 -99
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -70
  7. package/docs/packaging.md +115 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -31
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +380 -350
  14. package/package.json +3 -1
  15. package/src/auth/config.js +174 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +353 -344
  18. package/src/core/child-reaper.js +61 -0
  19. package/src/core/config-home.js +134 -134
  20. package/src/core/events.js +105 -105
  21. package/src/core/hook-engine.js +329 -275
  22. package/src/core/hook-loader.js +824 -791
  23. package/src/core/hook-runner.js +566 -346
  24. package/src/core/logger.js +297 -171
  25. package/src/core/matcher.js +100 -51
  26. package/src/core/metrics.js +75 -75
  27. package/src/core/rename-migration.js +163 -0
  28. package/src/core/requires.js +138 -138
  29. package/src/core/server-config.js +98 -98
  30. package/src/extend/add-hook.md +152 -152
  31. package/src/extend/add-mcp.md +119 -122
  32. package/src/extend/configure.md +88 -87
  33. package/src/extend/create-tool.md +168 -168
  34. package/src/extend/howto.js +102 -102
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -99
  37. package/src/mcp/aggregator.js +1719 -1246
  38. package/src/mcp/expose-store.js +577 -460
  39. package/src/mcp/gated-run.js +210 -201
  40. package/src/mcp/http-transport.js +1563 -1311
  41. package/src/mcp/mcp-client.js +1070 -907
  42. package/src/mcp/modern.js +406 -372
  43. package/src/mcp/protocol.js +468 -468
  44. package/src/mcp/server.js +3112 -2688
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -170
  47. package/src/ui/public/app.js +1187 -1174
  48. package/src/ui/public/index.html +286 -286
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1348
  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 +120 -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 -121
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -134
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -103
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -227
  70. package/src/core/tool-registry.js +0 -180
  71. package/src/packages/packager.js +0 -715
  72. package/src/tools/drift.js +0 -138
@@ -1,350 +1,380 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * mcp/servers/mock-upstream/server.js
6
- *
7
- * A tiny, ZERO-DEPENDENCY stdio MCP server bundled inside ToolFunnel's own `mcp/` tree so it
8
- * travels with the install (the "toolbox" stays self-contained and portable). It exists to
9
- * demonstrate ToolFunnel attaching and forwarding (proxying) an upstream MCP: attach it as an
10
- * upstream, expose its tools, and watch ToolFunnel forward calls to it through the policy gate.
11
- *
12
- * Attach it with a RELATIVE arg path (the aggregator resolves it against the gateway root and
13
- * the isolation guard requires it to stay inside the tree):
14
- * upstream: { id: "mock", command: "node", args: ["mcp/servers/mock-upstream/server.js"] }
15
- *
16
- * Protocol: JSON-RPC 2.0 over stdio with NEWLINE-DELIMITED framing - one compact JSON object per
17
- * line, "\n"-terminated, the MCP stdio transport every real SDK-based server speaks. We read lines
18
- * on stdin and answer in kind on stdout. (This fixture used to parse LSP Content-Length framing,
19
- * matching an old client bug; both were corrected together so the mock represents a REAL server.)
20
- *
21
- * Tools exposed (real upstream names):
22
- * - ping -> "pong"
23
- * - add { a, b } -> a + b
24
- * - echo { text } -> text
25
- * - blocks -> a MULTI-BLOCK result with structuredContent (envelope-fidelity fixture)
26
- *
27
- * Replies to `initialize`, ignores `notifications/initialized`, answers `tools/list`,
28
- * `tools/call` and `resources/list` (one fixed resource - forward-fidelity fixture),
29
- * never crashes on a bad frame, and exits cleanly on stdin EOF.
30
- *
31
- * Node built-ins only. CommonJS.
32
- */
33
-
34
- const PROTOCOL_VERSION = '2024-11-05';
35
- // `title` is a fidelity fixture: the 2025-06-18 spec added serverInfo.title, and the wrap must
36
- // deliver it VERBATIM (a whitelist in wrappedIdentity used to eat it - wrap-lab, 2026-07-17).
37
- const SERVER_INFO = { name: 'mock-upstream', version: '1.0.0', title: 'Mock Upstream Fixture' };
38
-
39
- const TOOLS = [
40
- {
41
- name: 'ping',
42
- description: 'Health check - returns the string "pong".',
43
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
44
- },
45
- {
46
- // title + annotations are fidelity fixtures: real servers ship them (and newer fields), and a
47
- // transparent wrap must advertise the def BYTE-IDENTICAL (a projection used to drop both).
48
- name: 'add',
49
- title: 'Add Numbers',
50
- description: 'Add two numbers and return the sum.',
51
- inputSchema: {
52
- type: 'object',
53
- properties: { a: { type: 'number' }, b: { type: 'number' } },
54
- required: ['a', 'b'],
55
- additionalProperties: false,
56
- },
57
- annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
58
- },
59
- {
60
- name: 'echo',
61
- description: 'Echo back the supplied text.',
62
- inputSchema: {
63
- type: 'object',
64
- properties: { text: { type: 'string' } },
65
- required: ['text'],
66
- additionalProperties: false,
67
- },
68
- },
69
- {
70
- name: 'blocks',
71
- description: 'Return a multi-block result with structuredContent (envelope-fidelity fixture).',
72
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
73
- },
74
- {
75
- name: 'crash',
76
- description: 'Exit the process immediately (simulates an upstream crash - for reconnect testing).',
77
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
78
- },
79
- {
80
- name: 'prog',
81
- description: 'Progress fixture: emits two notifications/progress against the request _meta progressToken, then returns.',
82
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
83
- },
84
- {
85
- name: 'slowtool',
86
- description: 'In-flight tools/call fixture: replies after 2s - the cancel-translation window for the TOOL path.',
87
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
88
- },
89
- {
90
- name: 'elicit',
91
- description: 'Elicitation fixture (Bridge B): sends a server-initiated elicitation/create mid-call, holds the call open, completes from the answer.',
92
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
93
- },
94
- ];
95
-
96
- // Outstanding SERVER->CLIENT requests this mock has issued (Bridge B fixture): id -> callback fed
97
- // with the client's response ({ result } or { error }).
98
- const outstandingServerRequests = new Map();
99
- let nextServerRequestId = 9001;
100
-
101
- /** Write one JSON-RPC message as a newline-delimited line - the MCP stdio framing. */
102
- function writeMessage(msg) {
103
- process.stdout.write(JSON.stringify(msg) + '\n');
104
- }
105
-
106
- /** Standard MCP tools/call result envelope. */
107
- function textResult(text, isError) {
108
- return { content: [{ type: 'text', text: String(text) }], isError: isError === true };
109
- }
110
-
111
- /** Dispatch one parsed JSON-RPC message. Notifications (no id) get no reply. */
112
- function handleMessage(msg) {
113
- if (!msg || typeof msg !== 'object') return;
114
- const id = msg.id;
115
- const method = msg.method;
116
- const params = msg.params || {};
117
-
118
- // Notifications carry no id and must never be answered. Cancel-fidelity fixture: when
119
- // TF_MOCK_CANCEL_LOG is set, RECORD each received notifications/cancelled requestId - a
120
- // gateway test can then prove which id (translated or raw) actually reached this server.
121
- if (id === undefined || id === null) {
122
- if (method === 'notifications/cancelled' && process.env.TF_MOCK_CANCEL_LOG) {
123
- try {
124
- require('node:fs').appendFileSync(process.env.TF_MOCK_CANCEL_LOG, String(params.requestId) + '\n');
125
- } catch (_e) { /* fixture logging must never crash the mock */ }
126
- }
127
- return;
128
- }
129
-
130
- // A message with an id and NO method is a client RESPONSE - route it to the outstanding
131
- // server-initiated request it answers (Bridge B fixture). Unknown ids are dropped.
132
- if (method === undefined) {
133
- const cb = outstandingServerRequests.get(id);
134
- if (cb) {
135
- outstandingServerRequests.delete(id);
136
- try { cb(msg); } catch (_e) { /* fixture callback must never crash the mock */ }
137
- }
138
- return;
139
- }
140
-
141
- switch (method) {
142
- case 'initialize':
143
- // Identity-mirroring fixture: RECORD the clientInfo each connect presents when
144
- // TF_MOCK_CLIENTINFO_LOG is set. A wire test reads the log across a reconnect - first
145
- // entry = the gateway's boot identity, last entry = the mirrored downstream client.
146
- if (process.env.TF_MOCK_CLIENTINFO_LOG) {
147
- try {
148
- require('node:fs').appendFileSync(process.env.TF_MOCK_CLIENTINFO_LOG,
149
- JSON.stringify((params && params.clientInfo) || null) + '\n');
150
- } catch (_e) { /* fixture logging must never crash the mock */ }
151
- }
152
- return writeMessage({
153
- jsonrpc: '2.0',
154
- id,
155
- result: {
156
- protocolVersion: PROTOCOL_VERSION,
157
- serverInfo: SERVER_INFO,
158
- // resources.subscribe: the per-URI update fixture - a legacy server only emits
159
- // resources/updated AFTER a resources/subscribe, which is exactly the channel the
160
- // gateway must forward, replay on reconnect, and bridge back.
161
- capabilities: { tools: {}, resources: { subscribe: true } },
162
- },
163
- });
164
-
165
- case 'tools/list':
166
- return writeMessage({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
167
-
168
- case 'server/discover':
169
- // Era-probe fixture: TF_MOCK_DISCOVER_EMPTY=1 makes this server ACK unknown methods with `{}`
170
- // - the "permissive legacy server" shape. A strict dual-era client probe must treat that as
171
- // LEGACY (no supportedVersions listing the modern version). Default: honest -32601.
172
- if (process.env.TF_MOCK_DISCOVER_EMPTY === '1') {
173
- return writeMessage({ jsonrpc: '2.0', id, result: {} });
174
- }
175
- return writeMessage({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found: ' + method } });
176
-
177
- case 'resources/list':
178
- // Forward-fidelity fixture: a distinctive verbatim result so a gateway test can PROVE a
179
- // forwarded non-tool method really reached this server (and came back untouched).
180
- return writeMessage({
181
- jsonrpc: '2.0', id,
182
- result: { resources: [{ uri: 'mock://upstream/readme', name: 'readme', mimeType: 'text/plain' }] },
183
- });
184
-
185
- case 'resources/subscribe': {
186
- // Per-URI update fixture. Ack, RECORD the subscribe when TF_MOCK_SUB_LOG is set (a replay
187
- // test counts subscribes across a process swap - a fresh process logging a fresh subscribe
188
- // PROVES the gateway replayed it), then emit one resources/updated shortly after.
189
- const uri = typeof params.uri === 'string' ? params.uri : '';
190
- if (process.env.TF_MOCK_SUB_LOG) {
191
- try {
192
- require('node:fs').appendFileSync(process.env.TF_MOCK_SUB_LOG, uri + '\n');
193
- } catch (_e) { /* fixture logging must never crash the mock */ }
194
- }
195
- writeMessage({ jsonrpc: '2.0', id, result: {} });
196
- setTimeout(() => writeMessage({
197
- jsonrpc: '2.0', method: 'notifications/resources/updated', params: { uri },
198
- }), 150);
199
- return;
200
- }
201
-
202
- case 'resources/read': {
203
- const uri = params.uri;
204
- if (uri === 'mock://upstream/slow') {
205
- // In-flight fixture: reply after 2s so a gateway test has a WIDE window to fire a
206
- // cancel at a genuinely outstanding forwarded request (400ms made W1 a flake candidate
207
- // on a loaded runner).
208
- setTimeout(() => writeMessage({
209
- jsonrpc: '2.0', id,
210
- result: { contents: [{ uri, mimeType: 'text/plain', text: 'slow done' }] },
211
- }), 2000);
212
- return;
213
- }
214
- if (uri === 'mock://upstream/readme') {
215
- return writeMessage({
216
- jsonrpc: '2.0', id,
217
- result: { contents: [{ uri, mimeType: 'text/plain', text: 'mock upstream readme' }] },
218
- });
219
- }
220
- return writeMessage({ jsonrpc: '2.0', id, error: { code: -32602, message: 'Unknown resource: ' + uri } });
221
- }
222
-
223
- case 'tools/call': {
224
- const name = params.name;
225
- const args = params.arguments || {};
226
- try {
227
- if (name === 'ping') {
228
- return writeMessage({ jsonrpc: '2.0', id, result: textResult('pong') });
229
- }
230
- if (name === 'add') {
231
- const a = Number(args.a);
232
- const b = Number(args.b);
233
- if (!isFinite(a) || !isFinite(b)) {
234
- return writeMessage({ jsonrpc: '2.0', id, result: textResult('add requires numeric a and b', true) });
235
- }
236
- return writeMessage({ jsonrpc: '2.0', id, result: textResult(String(a + b)) });
237
- }
238
- if (name === 'echo') {
239
- const text = typeof args.text === 'string' ? args.text : JSON.stringify(args.text);
240
- return writeMessage({ jsonrpc: '2.0', id, result: textResult(text) });
241
- }
242
- if (name === 'blocks') {
243
- // Envelope-fidelity fixture: two content blocks AND structuredContent. A transparent
244
- // forward must deliver this envelope VERBATIM - any re-shaping collapses it.
245
- return writeMessage({
246
- jsonrpc: '2.0', id,
247
- result: {
248
- content: [
249
- { type: 'text', text: 'first block' },
250
- { type: 'text', text: 'second block' },
251
- ],
252
- structuredContent: { first: 'first block', second: 'second block' },
253
- isError: false,
254
- },
255
- });
256
- }
257
- if (name === 'crash') {
258
- // Simulate an upstream crash: exit WITHOUT replying. The gateway's in-flight request
259
- // rejects and its McpClient.onClose fires -> the aggregator schedules a background
260
- // reconnect. Exercised by reconnect.test.js.
261
- process.exit(1);
262
- }
263
- if (name === 'slowtool') {
264
- // The tool-path twin of mock://upstream/slow: a genuinely outstanding tools/call for
265
- // cancel-translation tests.
266
- setTimeout(() => writeMessage({ jsonrpc: '2.0', id, result: textResult('slowtool done') }), 2000);
267
- return;
268
- }
269
- if (name === 'elicit') {
270
- // Bridge B fixture: ask the CLIENT a question mid-call, hold the tools/call open, and
271
- // complete it from the answer - exactly what a real eliciting legacy server does.
272
- const reqId = nextServerRequestId++;
273
- outstandingServerRequests.set(reqId, (resp) => {
274
- const res = resp && resp.result;
275
- if (resp && resp.error) {
276
- return writeMessage({ jsonrpc: '2.0', id, result: textResult('elicit error: ' + resp.error.message, true) });
277
- }
278
- const action = res && res.action;
279
- if (action === 'accept') {
280
- const colour = res.content && res.content.colour;
281
- return writeMessage({ jsonrpc: '2.0', id, result: textResult('colour: ' + colour) });
282
- }
283
- return writeMessage({ jsonrpc: '2.0', id, result: textResult(String(action || 'no action')) });
284
- });
285
- // NO `mode` field - deliberately. Real pre-MRTR legacy upstreams send only
286
- // {message, requestedSchema}; the BRIDGE must inject mode:"form" for the modern shape,
287
- // and a fixture that includes it would mask that translation.
288
- writeMessage({
289
- jsonrpc: '2.0', id: reqId, method: 'elicitation/create',
290
- params: {
291
- message: 'Pick a colour',
292
- requestedSchema: { type: 'object', properties: { colour: { type: 'string' } }, required: ['colour'] },
293
- },
294
- });
295
- return; // the tools/call reply comes from the callback above
296
- }
297
- if (name === 'prog') {
298
- // Progress fixture: a real server correlates progress via the REQUEST's _meta
299
- // progressToken. If the gateway stripped _meta on the forward, token is
300
- // undefined and no progress is emitted - the test's zero-count is the failure signal.
301
- const token = params._meta && params._meta.progressToken;
302
- if (token !== undefined) {
303
- writeMessage({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, progress: 1, total: 2 } });
304
- writeMessage({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, progress: 2, total: 2 } });
305
- }
306
- return writeMessage({ jsonrpc: '2.0', id, result: textResult(token !== undefined ? 'prog done (token seen)' : 'prog done (NO token)') });
307
- }
308
- return writeMessage({ jsonrpc: '2.0', id, result: textResult('unknown tool: ' + name, true) });
309
- } catch (err) {
310
- return writeMessage({ jsonrpc: '2.0', id, result: textResult('error: ' + ((err && err.message) || err), true) });
311
- }
312
- }
313
-
314
- default:
315
- // Unknown method -> a well-formed JSON-RPC method-not-found error.
316
- return writeMessage({
317
- jsonrpc: '2.0',
318
- id,
319
- error: { code: -32601, message: 'Method not found: ' + method },
320
- });
321
- }
322
- }
323
-
324
- // ── Newline-delimited JSON reader over stdin (the MCP stdio framing) ─────────────────
325
- let buffer = '';
326
-
327
- process.stdin.on('data', (chunk) => {
328
- buffer += chunk.toString('utf8');
329
- // Drain every complete line currently buffered (one JSON-RPC message per line).
330
- let nl;
331
- while ((nl = buffer.indexOf('\n')) !== -1) {
332
- const line = buffer.slice(0, nl).replace(/\r$/, '').trim();
333
- buffer = buffer.slice(nl + 1);
334
- if (line.length === 0) continue; // blank separator line - ignore
335
- let msg;
336
- try {
337
- msg = JSON.parse(line);
338
- } catch (_e) {
339
- continue; // skip an unparseable line rather than crash
340
- }
341
- try {
342
- handleMessage(msg);
343
- } catch (_e) {
344
- /* a handler must never take the server down */
345
- }
346
- }
347
- });
348
-
349
- process.stdin.on('end', () => process.exit(0));
350
- process.stdin.resume();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * mcp/servers/mock-upstream/server.js
6
+ *
7
+ * A tiny, ZERO-DEPENDENCY stdio MCP server bundled inside ToolFunnel's own `mcp/` tree so it
8
+ * travels with the install (the "toolbox" stays self-contained and portable). It exists to
9
+ * demonstrate ToolFunnel attaching and forwarding (proxying) an upstream MCP: attach it as an
10
+ * upstream, expose its tools, and watch ToolFunnel forward calls to it through the policy gate.
11
+ *
12
+ * Attach it with a RELATIVE arg path (the aggregator resolves it against the gateway root and
13
+ * the isolation guard requires it to stay inside the tree):
14
+ * upstream: { id: "mock", command: "node", args: ["mcp/servers/mock-upstream/server.js"] }
15
+ *
16
+ * Protocol: JSON-RPC 2.0 over stdio with NEWLINE-DELIMITED framing - one compact JSON object per
17
+ * line, "\n"-terminated, the MCP stdio transport every real SDK-based server speaks. We read lines
18
+ * on stdin and answer in kind on stdout. (This fixture used to parse LSP Content-Length framing,
19
+ * matching an old client bug; both were corrected together so the mock represents a REAL server.)
20
+ *
21
+ * Tools exposed (real upstream names):
22
+ * - ping -> "pong"
23
+ * - add { a, b } -> a + b
24
+ * - echo { text } -> text
25
+ * - blocks -> a MULTI-BLOCK result with structuredContent (envelope-fidelity fixture)
26
+ *
27
+ * Replies to `initialize`, ignores `notifications/initialized`, answers `tools/list`,
28
+ * `tools/call` and `resources/list` (one fixed resource - forward-fidelity fixture),
29
+ * never crashes on a bad frame, and exits cleanly on stdin EOF.
30
+ *
31
+ * Node built-ins only. CommonJS.
32
+ */
33
+
34
+ const PROTOCOL_VERSION = '2024-11-05';
35
+ // `title` is a fidelity fixture: the 2025-06-18 spec added serverInfo.title, and the wrap must
36
+ // deliver it VERBATIM (a whitelist in wrappedIdentity used to eat it - found wire-testing the wrap, 2026-07-17).
37
+ const SERVER_INFO = { name: 'mock-upstream', version: '1.0.0', title: 'Mock Upstream Fixture' };
38
+
39
+ const TOOLS = [
40
+ {
41
+ name: 'ping',
42
+ description: 'Health check - returns the string "pong".',
43
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
44
+ },
45
+ {
46
+ // title + annotations are fidelity fixtures: real servers ship them (and newer fields), and a
47
+ // transparent wrap must advertise the def BYTE-IDENTICAL (a projection used to drop both).
48
+ name: 'add',
49
+ title: 'Add Numbers',
50
+ description: 'Add two numbers and return the sum.',
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: { a: { type: 'number' }, b: { type: 'number' } },
54
+ required: ['a', 'b'],
55
+ additionalProperties: false,
56
+ },
57
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
58
+ },
59
+ {
60
+ name: 'echo',
61
+ description: 'Echo back the supplied text.',
62
+ inputSchema: {
63
+ type: 'object',
64
+ properties: { text: { type: 'string' } },
65
+ required: ['text'],
66
+ additionalProperties: false,
67
+ },
68
+ },
69
+ {
70
+ name: 'blocks',
71
+ description: 'Return a multi-block result with structuredContent (envelope-fidelity fixture).',
72
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
73
+ },
74
+ {
75
+ name: 'crash',
76
+ description: 'Exit the process immediately (simulates an upstream crash - for reconnect testing).',
77
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
78
+ },
79
+ {
80
+ name: 'prog',
81
+ description: 'Progress fixture: emits two notifications/progress against the request _meta progressToken, then returns.',
82
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
83
+ },
84
+ {
85
+ name: 'slowtool',
86
+ description: 'In-flight tools/call fixture: replies after 2s - the cancel-translation window for the TOOL path.',
87
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
88
+ },
89
+ {
90
+ name: 'elicit',
91
+ description: 'Elicitation fixture: sends a server-initiated elicitation/create mid-call, holds the call open, completes from the answer.',
92
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
93
+ },
94
+ ];
95
+
96
+ // Outstanding SERVER->CLIENT requests this mock has issued (elicitation-bridge fixture): id -> callback fed
97
+ // with the client's response ({ result } or { error }).
98
+ const outstandingServerRequests = new Map();
99
+ let nextServerRequestId = 9001;
100
+
101
+ /** Write one JSON-RPC message as a newline-delimited line - the MCP stdio framing. */
102
+ function writeMessage(msg) {
103
+ process.stdout.write(JSON.stringify(msg) + '\n');
104
+ }
105
+
106
+ /** Standard MCP tools/call result envelope. */
107
+ function textResult(text, isError) {
108
+ return { content: [{ type: 'text', text: String(text) }], isError: isError === true };
109
+ }
110
+
111
+ /** Dispatch one parsed JSON-RPC message. Notifications (no id) get no reply. */
112
+ function handleMessage(msg) {
113
+ if (!msg || typeof msg !== 'object') return;
114
+ const id = msg.id;
115
+ const method = msg.method;
116
+ const params = msg.params || {};
117
+
118
+ // Era-negotiation fixture: when TF_MOCK_METHOD_LOG is set, RECORD every incoming method in
119
+ // arrival order. A reconnect test reads the log to prove WHICH negotiation a fresh connection
120
+ // ran (server/discover probe vs straight initialize) - the wire is the only honest witness.
121
+ if (typeof method === 'string' && process.env.TF_MOCK_METHOD_LOG) {
122
+ try {
123
+ require('node:fs').appendFileSync(process.env.TF_MOCK_METHOD_LOG, method + '\n');
124
+ } catch (_e) { /* fixture logging must never crash the mock */ }
125
+ }
126
+
127
+ // Notifications carry no id and must never be answered. Cancel-fidelity fixture: when
128
+ // TF_MOCK_CANCEL_LOG is set, RECORD each received notifications/cancelled requestId - a
129
+ // gateway test can then prove which id (translated or raw) actually reached this server.
130
+ if (id === undefined || id === null) {
131
+ if (method === 'notifications/cancelled' && process.env.TF_MOCK_CANCEL_LOG) {
132
+ try {
133
+ require('node:fs').appendFileSync(process.env.TF_MOCK_CANCEL_LOG, String(params.requestId) + '\n');
134
+ } catch (_e) { /* fixture logging must never crash the mock */ }
135
+ }
136
+ return;
137
+ }
138
+
139
+ // A message with an id and NO method is a client RESPONSE - route it to the outstanding
140
+ // server-initiated request it answers (elicitation-bridge fixture). Unknown ids are dropped.
141
+ if (method === undefined) {
142
+ const cb = outstandingServerRequests.get(id);
143
+ if (cb) {
144
+ outstandingServerRequests.delete(id);
145
+ try { cb(msg); } catch (_e) { /* fixture callback must never crash the mock */ }
146
+ }
147
+ return;
148
+ }
149
+
150
+ switch (method) {
151
+ case 'initialize':
152
+ // Era-memo self-heal fixture: while the file named by TF_MOCK_INIT_REFUSE_FILE EXISTS,
153
+ // REFUSE the legacy handshake - the shape of an upstream upgraded to modern-only across a
154
+ // restart. File-gated (not env-gated) so a test can flip the behaviour BETWEEN respawns of
155
+ // this same fixture without touching the upstream's configured env.
156
+ if (process.env.TF_MOCK_INIT_REFUSE_FILE &&
157
+ require('node:fs').existsSync(process.env.TF_MOCK_INIT_REFUSE_FILE)) {
158
+ return writeMessage({
159
+ jsonrpc: '2.0', id,
160
+ error: { code: -32000, message: 'initialize refused (TF_MOCK_INIT_REFUSE_FILE fixture)' },
161
+ });
162
+ }
163
+ // Identity-mirroring fixture: RECORD the clientInfo each connect presents when
164
+ // TF_MOCK_CLIENTINFO_LOG is set. A wire test reads the log across a reconnect - first
165
+ // entry = the gateway's boot identity, last entry = the mirrored downstream client.
166
+ if (process.env.TF_MOCK_CLIENTINFO_LOG) {
167
+ try {
168
+ require('node:fs').appendFileSync(process.env.TF_MOCK_CLIENTINFO_LOG,
169
+ JSON.stringify((params && params.clientInfo) || null) + '\n');
170
+ } catch (_e) { /* fixture logging must never crash the mock */ }
171
+ }
172
+ return writeMessage({
173
+ jsonrpc: '2.0',
174
+ id,
175
+ result: {
176
+ protocolVersion: PROTOCOL_VERSION,
177
+ serverInfo: SERVER_INFO,
178
+ // resources.subscribe: the per-URI update fixture - a legacy server only emits
179
+ // resources/updated AFTER a resources/subscribe, which is exactly the channel the
180
+ // gateway must forward, replay on reconnect, and bridge back.
181
+ capabilities: { tools: {}, resources: { subscribe: true } },
182
+ },
183
+ });
184
+
185
+ case 'tools/list':
186
+ // Era-memo invariant fixture: while the file named by TF_MOCK_LIST_FAIL_FILE EXISTS,
187
+ // tools/list errors AFTER a successful handshake - the shape of an upstream that half-boots.
188
+ // File-gated so a test can flip the behaviour between respawns of this same fixture.
189
+ if (process.env.TF_MOCK_LIST_FAIL_FILE &&
190
+ require('node:fs').existsSync(process.env.TF_MOCK_LIST_FAIL_FILE)) {
191
+ return writeMessage({
192
+ jsonrpc: '2.0', id,
193
+ error: { code: -32000, message: 'tools/list unavailable (TF_MOCK_LIST_FAIL_FILE fixture)' },
194
+ });
195
+ }
196
+ return writeMessage({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
197
+
198
+ case 'server/discover':
199
+ // Era-probe fixture: TF_MOCK_DISCOVER_EMPTY=1 makes this server ACK unknown methods with `{}`
200
+ // - the "permissive legacy server" shape. A strict dual-era client probe must treat that as
201
+ // LEGACY (no supportedVersions listing the modern version). Default: honest -32601.
202
+ if (process.env.TF_MOCK_DISCOVER_EMPTY === '1') {
203
+ return writeMessage({ jsonrpc: '2.0', id, result: {} });
204
+ }
205
+ return writeMessage({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found: ' + method } });
206
+
207
+ case 'resources/list':
208
+ // Forward-fidelity fixture: a distinctive verbatim result so a gateway test can PROVE a
209
+ // forwarded non-tool method really reached this server (and came back untouched).
210
+ return writeMessage({
211
+ jsonrpc: '2.0', id,
212
+ result: { resources: [{ uri: 'mock://upstream/readme', name: 'readme', mimeType: 'text/plain' }] },
213
+ });
214
+
215
+ case 'resources/subscribe': {
216
+ // Per-URI update fixture. Ack, RECORD the subscribe when TF_MOCK_SUB_LOG is set (a replay
217
+ // test counts subscribes across a process swap - a fresh process logging a fresh subscribe
218
+ // PROVES the gateway replayed it), then emit one resources/updated shortly after.
219
+ const uri = typeof params.uri === 'string' ? params.uri : '';
220
+ if (process.env.TF_MOCK_SUB_LOG) {
221
+ try {
222
+ require('node:fs').appendFileSync(process.env.TF_MOCK_SUB_LOG, uri + '\n');
223
+ } catch (_e) { /* fixture logging must never crash the mock */ }
224
+ }
225
+ writeMessage({ jsonrpc: '2.0', id, result: {} });
226
+ setTimeout(() => writeMessage({
227
+ jsonrpc: '2.0', method: 'notifications/resources/updated', params: { uri },
228
+ }), 150);
229
+ return;
230
+ }
231
+
232
+ case 'resources/read': {
233
+ const uri = params.uri;
234
+ if (uri === 'mock://upstream/slow') {
235
+ // In-flight fixture: reply after 2s so a gateway test has a WIDE window to fire a
236
+ // cancel at a genuinely outstanding forwarded request (400ms made the wire cancel test a flake candidate
237
+ // on a loaded runner).
238
+ setTimeout(() => writeMessage({
239
+ jsonrpc: '2.0', id,
240
+ result: { contents: [{ uri, mimeType: 'text/plain', text: 'slow done' }] },
241
+ }), 2000);
242
+ return;
243
+ }
244
+ if (uri === 'mock://upstream/readme') {
245
+ return writeMessage({
246
+ jsonrpc: '2.0', id,
247
+ result: { contents: [{ uri, mimeType: 'text/plain', text: 'mock upstream readme' }] },
248
+ });
249
+ }
250
+ return writeMessage({ jsonrpc: '2.0', id, error: { code: -32602, message: 'Unknown resource: ' + uri } });
251
+ }
252
+
253
+ case 'tools/call': {
254
+ const name = params.name;
255
+ const args = params.arguments || {};
256
+ try {
257
+ if (name === 'ping') {
258
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('pong') });
259
+ }
260
+ if (name === 'add') {
261
+ const a = Number(args.a);
262
+ const b = Number(args.b);
263
+ if (!isFinite(a) || !isFinite(b)) {
264
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('add requires numeric a and b', true) });
265
+ }
266
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult(String(a + b)) });
267
+ }
268
+ if (name === 'echo') {
269
+ const text = typeof args.text === 'string' ? args.text : JSON.stringify(args.text);
270
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult(text) });
271
+ }
272
+ if (name === 'blocks') {
273
+ // Envelope-fidelity fixture: two content blocks AND structuredContent. A transparent
274
+ // forward must deliver this envelope VERBATIM - any re-shaping collapses it.
275
+ return writeMessage({
276
+ jsonrpc: '2.0', id,
277
+ result: {
278
+ content: [
279
+ { type: 'text', text: 'first block' },
280
+ { type: 'text', text: 'second block' },
281
+ ],
282
+ structuredContent: { first: 'first block', second: 'second block' },
283
+ isError: false,
284
+ },
285
+ });
286
+ }
287
+ if (name === 'crash') {
288
+ // Simulate an upstream crash: exit WITHOUT replying. The gateway's in-flight request
289
+ // rejects and its McpClient.onClose fires -> the aggregator schedules a background
290
+ // reconnect. Exercised by reconnect.test.js.
291
+ process.exit(1);
292
+ }
293
+ if (name === 'slowtool') {
294
+ // The tool-path twin of mock://upstream/slow: a genuinely outstanding tools/call for
295
+ // cancel-translation tests.
296
+ setTimeout(() => writeMessage({ jsonrpc: '2.0', id, result: textResult('slowtool done') }), 2000);
297
+ return;
298
+ }
299
+ if (name === 'elicit') {
300
+ // elicitation-bridge fixture: ask the CLIENT a question mid-call, hold the tools/call open, and
301
+ // complete it from the answer - exactly what a real eliciting legacy server does.
302
+ const reqId = nextServerRequestId++;
303
+ outstandingServerRequests.set(reqId, (resp) => {
304
+ const res = resp && resp.result;
305
+ if (resp && resp.error) {
306
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('elicit error: ' + resp.error.message, true) });
307
+ }
308
+ const action = res && res.action;
309
+ if (action === 'accept') {
310
+ const colour = res.content && res.content.colour;
311
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('colour: ' + colour) });
312
+ }
313
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult(String(action || 'no action')) });
314
+ });
315
+ // NO `mode` field - deliberately. Real pre-MRTR legacy upstreams send only
316
+ // {message, requestedSchema}; the BRIDGE must inject mode:"form" for the modern shape,
317
+ // and a fixture that includes it would mask that translation.
318
+ writeMessage({
319
+ jsonrpc: '2.0', id: reqId, method: 'elicitation/create',
320
+ params: {
321
+ message: 'Pick a colour',
322
+ requestedSchema: { type: 'object', properties: { colour: { type: 'string' } }, required: ['colour'] },
323
+ },
324
+ });
325
+ return; // the tools/call reply comes from the callback above
326
+ }
327
+ if (name === 'prog') {
328
+ // Progress fixture: a real server correlates progress via the REQUEST's _meta
329
+ // progressToken. If the gateway stripped _meta on the forward, token is
330
+ // undefined and no progress is emitted - the test's zero-count is the failure signal.
331
+ const token = params._meta && params._meta.progressToken;
332
+ if (token !== undefined) {
333
+ writeMessage({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, progress: 1, total: 2 } });
334
+ writeMessage({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, progress: 2, total: 2 } });
335
+ }
336
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult(token !== undefined ? 'prog done (token seen)' : 'prog done (NO token)') });
337
+ }
338
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('unknown tool: ' + name, true) });
339
+ } catch (err) {
340
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('error: ' + ((err && err.message) || err), true) });
341
+ }
342
+ }
343
+
344
+ default:
345
+ // Unknown method -> a well-formed JSON-RPC method-not-found error.
346
+ return writeMessage({
347
+ jsonrpc: '2.0',
348
+ id,
349
+ error: { code: -32601, message: 'Method not found: ' + method },
350
+ });
351
+ }
352
+ }
353
+
354
+ // ── Newline-delimited JSON reader over stdin (the MCP stdio framing) ─────────────────
355
+ let buffer = '';
356
+
357
+ process.stdin.on('data', (chunk) => {
358
+ buffer += chunk.toString('utf8');
359
+ // Drain every complete line currently buffered (one JSON-RPC message per line).
360
+ let nl;
361
+ while ((nl = buffer.indexOf('\n')) !== -1) {
362
+ const line = buffer.slice(0, nl).replace(/\r$/, '').trim();
363
+ buffer = buffer.slice(nl + 1);
364
+ if (line.length === 0) continue; // blank separator line - ignore
365
+ let msg;
366
+ try {
367
+ msg = JSON.parse(line);
368
+ } catch (_e) {
369
+ continue; // skip an unparseable line rather than crash
370
+ }
371
+ try {
372
+ handleMessage(msg);
373
+ } catch (_e) {
374
+ /* a handler must never take the server down */
375
+ }
376
+ }
377
+ });
378
+
379
+ process.stdin.on('end', () => process.exit(0));
380
+ process.stdin.resume();