toolfunnel 0.5.0 → 0.7.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 (72) hide show
  1. package/README.md +235 -167
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -147
  4. package/docs/KNOWN_BUGS.md +80 -33
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -0
  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 -0
  13. package/mcp/servers/mock-upstream/server.js +222 -12
  14. package/package.json +67 -65
  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 -77
  30. package/src/extend/add-hook.md +152 -142
  31. package/src/extend/add-mcp.md +119 -121
  32. package/src/extend/configure.md +88 -0
  33. package/src/extend/create-tool.md +168 -152
  34. package/src/extend/howto.js +102 -98
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -0
  37. package/src/mcp/aggregator.js +1674 -807
  38. package/src/mcp/expose-store.js +577 -430
  39. package/src/mcp/gated-run.js +210 -194
  40. package/src/mcp/http-transport.js +1563 -1012
  41. package/src/mcp/mcp-client.js +1070 -530
  42. package/src/mcp/modern.js +406 -0
  43. package/src/mcp/protocol.js +468 -466
  44. package/src/mcp/server.js +3112 -1405
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1187 -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 +1616 -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 +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 -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 +14 -3
  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,30 +1,30 @@
1
- {
2
- "_README": "SAMPLE config — NOT the live expose.json. This wires the bundled demo upstream at mcp/servers/mock-upstream/server.js so the MCP Manager has a real target. TO USE: either (A) copy this file over mcp/expose.json to pre-seed the host (drop the _README key — the loader ignores it, but copy cleanly), OR (B) leave expose.json empty and add the same upstream + expose entries through the MCP Manager UI (Add upstream -> Discover -> tick to expose). The upstream offers ping, add, echo; this sample exposes only TWO (ping, add) to demonstrate curation. IMPORTANT: the args path MUST resolve INSIDE the gateway root — the aggregator's isolation guard rejects any arg path that escapes the sandbox. A relative path like 'mcp/servers/mock-upstream/server.js' is resolved against the gateway root and stays inside it; 'node' in the command slot is the interpreter (not guarded). Do NOT edit the live mcp/expose.json to point outside the gateway root.",
3
- "version": 1,
4
- "upstreams": [
5
- {
6
- "id": "mock",
7
- "transport": "stdio",
8
- "command": "node",
9
- "args": ["mcp/servers/mock-upstream/server.js"],
10
- "enabled": true,
11
- "description": "Bundled demo upstream MCP (zero-dependency, stdio): ping, add, echo. No network, no fs writes, no process mutation."
12
- }
13
- ],
14
- "expose": [
15
- {
16
- "upstream": "mock",
17
- "tool": "ping",
18
- "as": "mock_ping",
19
- "category": "demo",
20
- "enabled": true
21
- },
22
- {
23
- "upstream": "mock",
24
- "tool": "add",
25
- "as": "mock_add",
26
- "category": "demo",
27
- "enabled": true
28
- }
29
- ]
30
- }
1
+ {
2
+ "_README": "SAMPLE config - NOT the live expose.json. This wires the bundled demo upstream at mcp/servers/mock-upstream/server.js so the MCP Manager has a real target. TO USE: either (A) copy this file over mcp/expose.json to pre-seed the host (drop the _README key - the loader ignores it, but copy cleanly), OR (B) leave expose.json empty and add the same upstream + expose entries through the MCP Manager UI (Add upstream -> Discover -> tick to expose). The upstream offers ping, add, echo; this sample exposes only TWO (ping, add) to demonstrate curation. IMPORTANT: the args path MUST resolve INSIDE the gateway root - the aggregator's isolation guard rejects any arg path that escapes the sandbox. A relative path like 'mcp/servers/mock-upstream/server.js' is resolved against the gateway root and stays inside it; 'node' in the command slot is the interpreter (not guarded). Do NOT edit the live mcp/expose.json to point outside the gateway root.",
3
+ "version": 1,
4
+ "upstreams": [
5
+ {
6
+ "id": "mock",
7
+ "transport": "stdio",
8
+ "command": "node",
9
+ "args": ["mcp/servers/mock-upstream/server.js"],
10
+ "enabled": true,
11
+ "description": "Bundled demo upstream MCP (zero-dependency, stdio): ping, add, echo. No network, no fs writes, no process mutation."
12
+ }
13
+ ],
14
+ "expose": [
15
+ {
16
+ "upstream": "mock",
17
+ "tool": "ping",
18
+ "as": "mock_ping",
19
+ "category": "demo",
20
+ "enabled": true
21
+ },
22
+ {
23
+ "upstream": "mock",
24
+ "tool": "add",
25
+ "as": "mock_add",
26
+ "category": "demo",
27
+ "enabled": true
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * mcp/servers/mock-modern/server.js
6
+ *
7
+ * A tiny, ZERO-DEPENDENCY stdio MCP server that speaks the 2026-07-28 ("modern") protocol -
8
+ * the companion to mock-upstream (which speaks the legacy 2024-11-05 protocol). It exists to
9
+ * prove ToolFunnel's DUAL-ERA CLIENT: attach it as an upstream and ToolFunnel negotiates modern
10
+ * (probes server/discover, skips the initialize handshake, and puts a per-request `_meta` on
11
+ * every call). Over stdio the modern era needs only `_meta` - the Mcp-* headers are HTTP-only.
12
+ *
13
+ * Modern-only, deliberately STRICT so it proves the client is really speaking modern:
14
+ * - `initialize` -> -32601 (removed in the modern era; a modern-only server has no handshake)
15
+ * - `server/discover` -> { resultType, supportedVersions:['2026-07-28'], capabilities, _meta
16
+ * (serverInfo), instructions, ttlMs, cacheScope }
17
+ * - `tools/list` -> REQUIRES per-request `_meta` (else -32602); returns resultType + tools
18
+ * + ttlMs/cacheScope
19
+ * - `tools/call` -> REQUIRES per-request `_meta` (else -32602); returns resultType + content
20
+ *
21
+ * Tools (real upstream names): ping -> "pong", add {a,b} -> a+b, echo {text} -> text.
22
+ *
23
+ * JSON-RPC 2.0 over stdio, newline-delimited (one compact JSON object per line). Node built-ins
24
+ * only, CommonJS. Built to the RC spec (locked 2026-05-21); reconcile minor final deltas 28 July.
25
+ */
26
+
27
+ const MODERN_VERSION = '2026-07-28';
28
+ const SERVER_INFO = { name: 'mock-modern', version: '2.0.0' };
29
+ const META_VERSION = 'io.modelcontextprotocol/protocolVersion';
30
+ const META_SERVERINFO = 'io.modelcontextprotocol/serverInfo';
31
+
32
+ const TOOLS = [
33
+ { name: 'ping', description: 'Health check - returns "pong".', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
34
+ { name: 'add', description: 'Add two numbers.', inputSchema: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'], additionalProperties: false } },
35
+ { name: 'echo', description: 'Echo the given text.', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'], additionalProperties: false } },
36
+ ];
37
+
38
+ function writeMessage(obj) {
39
+ try { process.stdout.write(JSON.stringify(obj) + '\n'); } catch (_e) { /* never crash on a write */ }
40
+ }
41
+
42
+ /** A modern result carries resultType + the server's serverInfo in _meta. */
43
+ function modernResult(extra) {
44
+ return Object.assign({ resultType: 'complete', _meta: { [META_SERVERINFO]: SERVER_INFO } }, extra);
45
+ }
46
+
47
+ function textResult(text, isError) {
48
+ return modernResult({ content: [{ type: 'text', text: String(text) }], isError: isError === true });
49
+ }
50
+
51
+ /** A modern request MUST carry params._meta with BOTH the EXACT protocol version AND a
52
+ * clientCapabilities object - the spec's two per-request MUSTs. Strict on purpose: this
53
+ * fixture's job is to catch a client regression in EITHER field (a lax check here would let
54
+ * "tools/call works with _meta" pass while the client sent the wrong version or no caps). */
55
+ function hasModernMeta(params) {
56
+ const meta = params && params._meta;
57
+ if (!meta || typeof meta !== 'object') return false;
58
+ if (meta[META_VERSION] !== MODERN_VERSION) return false;
59
+ const caps = meta['io.modelcontextprotocol/clientCapabilities'];
60
+ return !!(caps && typeof caps === 'object' && !Array.isArray(caps));
61
+ }
62
+
63
+ function handleMessage(msg) {
64
+ if (!msg || typeof msg !== 'object') return;
65
+ const id = msg.id;
66
+ const method = msg.method;
67
+ const params = msg.params || {};
68
+ if (id === undefined || id === null) return; // notifications are never answered
69
+
70
+ switch (method) {
71
+ case 'server/discover':
72
+ return writeMessage({
73
+ jsonrpc: '2.0', id,
74
+ result: modernResult({
75
+ supportedVersions: [MODERN_VERSION],
76
+ capabilities: { tools: {} },
77
+ instructions: 'A modern (2026-07-28) mock MCP. Tools: ping, add, echo.',
78
+ ttlMs: 3600000,
79
+ cacheScope: 'private',
80
+ }),
81
+ });
82
+
83
+ case 'initialize':
84
+ // Modern-only: the initialize handshake was removed. Reject so a legacy client fails clearly.
85
+ return writeMessage({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found: initialize (this is a modern 2026-07-28 server - use server/discover)' } });
86
+
87
+ case 'tools/list':
88
+ if (!hasModernMeta(params)) {
89
+ return writeMessage({ jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params: modern requests require params._meta with a protocol version' } });
90
+ }
91
+ return writeMessage({ jsonrpc: '2.0', id, result: modernResult({ tools: TOOLS, ttlMs: 60000, cacheScope: 'private' }) });
92
+
93
+ case 'resources/list':
94
+ // A CacheableResult surface - proves non-tool forwarding under a wrap (and that the wrap adds
95
+ // the required ttlMs/cacheScope when the upstream's own hints are absent).
96
+ if (!hasModernMeta(params)) {
97
+ return writeMessage({ jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params: modern requests require params._meta with a protocol version' } });
98
+ }
99
+ return writeMessage({ jsonrpc: '2.0', id, result: modernResult({ resources: [{ uri: 'mock://modern/example', name: 'example' }] }) });
100
+
101
+ case 'tools/call': {
102
+ if (!hasModernMeta(params)) {
103
+ return writeMessage({ jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params: modern requests require params._meta with a protocol version' } });
104
+ }
105
+ const name = params.name;
106
+ const args = params.arguments || {};
107
+ if (name === 'ping') {
108
+ // PLUMBING FIXTURE, not conformant behaviour: a real modern server emits notifications
109
+ // only inside a subscriptions/listen stream, which ToolFunnel's client does not yet open
110
+ // (28-July item). This unsolicited emission exists solely so BR-A2 can prove the
111
+ // notification->hook plumbing; the ACK-level honesty (never counting a modern upstream as
112
+ // able to honour resourceSubscriptions) is asserted by BR-A3.
113
+ writeMessage({ jsonrpc: '2.0', method: 'notifications/resources/updated', params: { uri: 'mock://modern/example' } });
114
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('pong') });
115
+ }
116
+ if (name === 'add') {
117
+ const a = Number(args.a), b = Number(args.b);
118
+ if (!isFinite(a) || !isFinite(b)) return writeMessage({ jsonrpc: '2.0', id, result: textResult('add requires numeric a and b', true) });
119
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult(String(a + b)) });
120
+ }
121
+ if (name === 'echo') {
122
+ const text = typeof args.text === 'string' ? args.text : JSON.stringify(args.text);
123
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult(text) });
124
+ }
125
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('unknown tool: ' + name, true) });
126
+ }
127
+
128
+ default:
129
+ return writeMessage({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found: ' + method } });
130
+ }
131
+ }
132
+
133
+ let buffer = '';
134
+ process.stdin.on('data', (chunk) => {
135
+ buffer += chunk.toString('utf8');
136
+ let nl;
137
+ while ((nl = buffer.indexOf('\n')) !== -1) {
138
+ const line = buffer.slice(0, nl).replace(/\r$/, '').trim();
139
+ buffer = buffer.slice(nl + 1);
140
+ if (line.length === 0) continue;
141
+ let msg;
142
+ try { msg = JSON.parse(line); } catch (_e) { continue; }
143
+ try { handleMessage(msg); } catch (_e) { /* never take the server down */ }
144
+ }
145
+ });
146
+ process.stdin.on('end', () => process.exit(0));
147
+ process.stdin.resume();
@@ -13,7 +13,7 @@
13
13
  * the isolation guard requires it to stay inside the tree):
14
14
  * upstream: { id: "mock", command: "node", args: ["mcp/servers/mock-upstream/server.js"] }
15
15
  *
16
- * Protocol: JSON-RPC 2.0 over stdio with NEWLINE-DELIMITED framing — one compact JSON object per
16
+ * Protocol: JSON-RPC 2.0 over stdio with NEWLINE-DELIMITED framing - one compact JSON object per
17
17
  * line, "\n"-terminated, the MCP stdio transport every real SDK-based server speaks. We read lines
18
18
  * on stdin and answer in kind on stdout. (This fixture used to parse LSP Content-Length framing,
19
19
  * matching an old client bug; both were corrected together so the mock represents a REAL server.)
@@ -22,24 +22,31 @@
22
22
  * - ping -> "pong"
23
23
  * - add { a, b } -> a + b
24
24
  * - echo { text } -> text
25
+ * - blocks -> a MULTI-BLOCK result with structuredContent (envelope-fidelity fixture)
25
26
  *
26
- * Replies to `initialize`, ignores `notifications/initialized`, answers `tools/list` and
27
- * `tools/call`, never crashes on a bad frame, and exits cleanly on stdin EOF.
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.
28
30
  *
29
31
  * Node built-ins only. CommonJS.
30
32
  */
31
33
 
32
34
  const PROTOCOL_VERSION = '2024-11-05';
33
- const SERVER_INFO = { name: 'mock-upstream', version: '1.0.0' };
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' };
34
38
 
35
39
  const TOOLS = [
36
40
  {
37
41
  name: 'ping',
38
- description: 'Health check — returns the string "pong".',
42
+ description: 'Health check - returns the string "pong".',
39
43
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
40
44
  },
41
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).
42
48
  name: 'add',
49
+ title: 'Add Numbers',
43
50
  description: 'Add two numbers and return the sum.',
44
51
  inputSchema: {
45
52
  type: 'object',
@@ -47,6 +54,7 @@ const TOOLS = [
47
54
  required: ['a', 'b'],
48
55
  additionalProperties: false,
49
56
  },
57
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
50
58
  },
51
59
  {
52
60
  name: 'echo',
@@ -58,14 +66,39 @@ const TOOLS = [
58
66
  additionalProperties: false,
59
67
  },
60
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
+ },
61
74
  {
62
75
  name: 'crash',
63
- description: 'Exit the process immediately (simulates an upstream crash — for reconnect testing).',
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.',
64
92
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
65
93
  },
66
94
  ];
67
95
 
68
- /** Write one JSON-RPC message as a newline-delimited line — the MCP stdio framing. */
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. */
69
102
  function writeMessage(msg) {
70
103
  process.stdout.write(JSON.stringify(msg) + '\n');
71
104
  }
@@ -82,24 +115,141 @@ function handleMessage(msg) {
82
115
  const method = msg.method;
83
116
  const params = msg.params || {};
84
117
 
85
- // Notifications carry no id and must never be answered.
86
- if (id === undefined || id === null) return;
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
+ }
87
149
 
88
150
  switch (method) {
89
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
+ }
90
172
  return writeMessage({
91
173
  jsonrpc: '2.0',
92
174
  id,
93
175
  result: {
94
176
  protocolVersion: PROTOCOL_VERSION,
95
177
  serverInfo: SERVER_INFO,
96
- capabilities: { tools: {} },
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 } },
97
182
  },
98
183
  });
99
184
 
100
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
+ }
101
196
  return writeMessage({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
102
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
+
103
253
  case 'tools/call': {
104
254
  const name = params.name;
105
255
  const args = params.arguments || {};
@@ -119,12 +269,72 @@ function handleMessage(msg) {
119
269
  const text = typeof args.text === 'string' ? args.text : JSON.stringify(args.text);
120
270
  return writeMessage({ jsonrpc: '2.0', id, result: textResult(text) });
121
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
+ }
122
287
  if (name === 'crash') {
123
288
  // Simulate an upstream crash: exit WITHOUT replying. The gateway's in-flight request
124
- // rejects and its McpClient.onClose fires → the aggregator schedules a background
289
+ // rejects and its McpClient.onClose fires -> the aggregator schedules a background
125
290
  // reconnect. Exercised by reconnect.test.js.
126
291
  process.exit(1);
127
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
+ }
128
338
  return writeMessage({ jsonrpc: '2.0', id, result: textResult('unknown tool: ' + name, true) });
129
339
  } catch (err) {
130
340
  return writeMessage({ jsonrpc: '2.0', id, result: textResult('error: ' + ((err && err.message) || err), true) });
@@ -151,7 +361,7 @@ process.stdin.on('data', (chunk) => {
151
361
  while ((nl = buffer.indexOf('\n')) !== -1) {
152
362
  const line = buffer.slice(0, nl).replace(/\r$/, '').trim();
153
363
  buffer = buffer.slice(nl + 1);
154
- if (line.length === 0) continue; // blank separator line — ignore
364
+ if (line.length === 0) continue; // blank separator line - ignore
155
365
  let msg;
156
366
  try {
157
367
  msg = JSON.parse(line);