toolfunnel 0.7.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 (67) hide show
  1. package/README.md +228 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +451 -451
  4. package/docs/KNOWN_BUGS.md +80 -80
  5. package/docs/design.md +84 -84
  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 +39 -39
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -147
  12. package/mcp/servers/mock-upstream/server.js +380 -380
  13. package/package.json +67 -67
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +353 -353
  17. package/src/core/child-reaper.js +61 -61
  18. package/src/core/config-home.js +134 -134
  19. package/src/core/events.js +105 -105
  20. package/src/core/hook-engine.js +329 -329
  21. package/src/core/hook-loader.js +824 -824
  22. package/src/core/hook-runner.js +566 -566
  23. package/src/core/logger.js +297 -297
  24. package/src/core/matcher.js +100 -100
  25. package/src/core/metrics.js +75 -75
  26. package/src/core/rename-migration.js +163 -163
  27. package/src/core/requires.js +138 -138
  28. package/src/core/server-config.js +98 -98
  29. package/src/extend/add-hook.md +152 -152
  30. package/src/extend/add-mcp.md +119 -119
  31. package/src/extend/configure.md +88 -88
  32. package/src/extend/create-tool.md +168 -168
  33. package/src/extend/howto.js +102 -102
  34. package/src/extend/package.md +190 -190
  35. package/src/extend/wrap.md +99 -99
  36. package/src/mcp/aggregator.js +1719 -1674
  37. package/src/mcp/expose-store.js +577 -577
  38. package/src/mcp/gated-run.js +210 -210
  39. package/src/mcp/http-transport.js +1563 -1563
  40. package/src/mcp/mcp-client.js +1070 -1070
  41. package/src/mcp/modern.js +406 -406
  42. package/src/mcp/protocol.js +468 -468
  43. package/src/mcp/server.js +3112 -3112
  44. package/src/tools/registry.js +661 -661
  45. package/src/tools/tool-state.js +170 -170
  46. package/src/ui/public/app.js +1187 -1187
  47. package/src/ui/public/index.html +286 -286
  48. package/src/ui/public/styles.css +535 -535
  49. package/src/ui/server.js +1616 -1616
  50. package/tools/scripts/base64.js +139 -139
  51. package/tools/scripts/danger.js +61 -61
  52. package/tools/scripts/echo.js +39 -39
  53. package/tools/scripts/hash.js +101 -101
  54. package/tools/scripts/json.js +109 -109
  55. package/tools/scripts/text-stats.js +131 -131
  56. package/tools/scripts/tf-env.js +43 -43
  57. package/tools/scripts/tf-hook-add.js +151 -151
  58. package/tools/scripts/tf-hook-set.js +120 -120
  59. package/tools/scripts/tf-list.js +192 -192
  60. package/tools/scripts/tf-log.js +86 -86
  61. package/tools/scripts/tf-mcp-add.js +117 -117
  62. package/tools/scripts/tf-mcp-set.js +121 -121
  63. package/tools/scripts/tf-pack.js +237 -237
  64. package/tools/scripts/tf-tool-add.js +134 -134
  65. package/tools/scripts/tf-tool-set.js +142 -142
  66. package/tools/scripts/tf-wrap.js +103 -103
  67. package/tools/scripts/uuid.js +102 -102
@@ -1,21 +1,21 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * EXAMPLE PostToolUse observer - DISABLED by default.
6
- *
7
- * PostToolUse is ADVISORY: it runs AFTER a tool has executed and CANNOT un-run it - it observes the
8
- * outcome. The lifecycle event (including the tool result) arrives as JSON on stdin. This stub just
9
- * exits 0 (a clean pass). Replace the body to forward results to your own audit sink, metrics, an
10
- * external SIEM, etc. Exit 0 = observed; a non-zero exit on PostToolUse is a non-blocking error.
11
- *
12
- * Zero dependencies - Node built-ins only.
13
- */
14
-
15
- let raw = '';
16
- process.stdin.on('data', (c) => { raw += c; });
17
- process.stdin.on('end', () => {
18
- // let ev = JSON.parse(raw || '{}'); // { tool_name, tool_input, tool_response, ... }
19
- // ... forward ev to your audit sink here ...
20
- process.exit(0);
21
- });
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * EXAMPLE PostToolUse observer - DISABLED by default.
6
+ *
7
+ * PostToolUse is ADVISORY: it runs AFTER a tool has executed and CANNOT un-run it - it observes the
8
+ * outcome. The lifecycle event (including the tool result) arrives as JSON on stdin. This stub just
9
+ * exits 0 (a clean pass). Replace the body to forward results to your own audit sink, metrics, an
10
+ * external SIEM, etc. Exit 0 = observed; a non-zero exit on PostToolUse is a non-blocking error.
11
+ *
12
+ * Zero dependencies - Node built-ins only.
13
+ */
14
+
15
+ let raw = '';
16
+ process.stdin.on('data', (c) => { raw += c; });
17
+ process.stdin.on('end', () => {
18
+ // let ev = JSON.parse(raw || '{}'); // { tool_name, tool_input, tool_response, ... }
19
+ // ... forward ev to your audit sink here ...
20
+ process.exit(0);
21
+ });
@@ -1,39 +1,39 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * EXAMPLE PreToolUse gate - DISABLED by default (enable it in hooks/hooks.manifest.json, or via the
6
- * UI Hooks tab / the per-tool "Pre" toggle on the Tools tab).
7
- *
8
- * This is the shape of a real policy gate: it runs INSIDE the gateway, BEFORE a tool executes, and
9
- * `process.exit(2)` DENIES the call. The lifecycle event arrives as JSON on stdin; here we block any
10
- * call whose arguments contain an obviously-destructive shell pattern.
11
- *
12
- * Block protocol (the simple one): write the reason to stderr and `process.exit(2)`. (A richer JSON
13
- * protocol is also supported - see docs/MANUAL.pdf section 10.) Exit 0 = allow.
14
- *
15
- * EXIT 2, NOT "non-zero" - the difference matters when you write your own. Every OTHER exit (a
16
- * crash, a syntax error, a missing file, a timeout) is a NON-BLOCKING ERROR and the call proceeds,
17
- * so `exit(1)` on an error path allows the very call you meant to stop. If your policy cannot
18
- * reach a decision, exit 2 deliberately. (The gateway does refuse a decision it cannot READ -
19
- * output past the cap, unparseable JSON - because an unreadable answer is not permission. It
20
- * cannot do the same for a script that never answered: one broken hook file would take every tool
21
- * offline.) test/gate-semantics.test.js pins all of this.
22
- *
23
- * Zero dependencies - Node built-ins only.
24
- */
25
-
26
- let raw = '';
27
- process.stdin.on('data', (c) => { raw += c; });
28
- process.stdin.on('end', () => {
29
- let ev = {};
30
- try { ev = JSON.parse(raw || '{}'); } catch (_e) { /* malformed payload -> allow (don't block on a parse error) */ }
31
- const args = JSON.stringify((ev && ev.tool_input) || {});
32
- // A small illustrative denylist: recursive-force delete, mkfs, drive format, fork bomb.
33
- const DANGER = /\brm\s+-[a-z]*r[a-z]*f|\bmkfs\b|\bformat\s+[a-z]:|:\(\)\s*\{\s*:/i;
34
- if (DANGER.test(args)) {
35
- process.stderr.write('example-deny-dangerous: blocked - the arguments contain a destructive pattern.\n');
36
- process.exit(2); // DENY - the gateway refuses to run the tool
37
- }
38
- process.exit(0); // allow
39
- });
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * EXAMPLE PreToolUse gate - DISABLED by default (enable it in hooks/hooks.manifest.json, or via the
6
+ * UI Hooks tab / the per-tool "Pre" toggle on the Tools tab).
7
+ *
8
+ * This is the shape of a real policy gate: it runs INSIDE the gateway, BEFORE a tool executes, and
9
+ * `process.exit(2)` DENIES the call. The lifecycle event arrives as JSON on stdin; here we block any
10
+ * call whose arguments contain an obviously-destructive shell pattern.
11
+ *
12
+ * Block protocol (the simple one): write the reason to stderr and `process.exit(2)`. (A richer JSON
13
+ * protocol is also supported - see docs/MANUAL.pdf section 10.) Exit 0 = allow.
14
+ *
15
+ * EXIT 2, NOT "non-zero" - the difference matters when you write your own. Every OTHER exit (a
16
+ * crash, a syntax error, a missing file, a timeout) is a NON-BLOCKING ERROR and the call proceeds,
17
+ * so `exit(1)` on an error path allows the very call you meant to stop. If your policy cannot
18
+ * reach a decision, exit 2 deliberately. (The gateway does refuse a decision it cannot READ -
19
+ * output past the cap, unparseable JSON - because an unreadable answer is not permission. It
20
+ * cannot do the same for a script that never answered: one broken hook file would take every tool
21
+ * offline.) test/gate-semantics.test.js pins all of this.
22
+ *
23
+ * Zero dependencies - Node built-ins only.
24
+ */
25
+
26
+ let raw = '';
27
+ process.stdin.on('data', (c) => { raw += c; });
28
+ process.stdin.on('end', () => {
29
+ let ev = {};
30
+ try { ev = JSON.parse(raw || '{}'); } catch (_e) { /* malformed payload -> allow (don't block on a parse error) */ }
31
+ const args = JSON.stringify((ev && ev.tool_input) || {});
32
+ // A small illustrative denylist: recursive-force delete, mkfs, drive format, fork bomb.
33
+ const DANGER = /\brm\s+-[a-z]*r[a-z]*f|\bmkfs\b|\bformat\s+[a-z]:|:\(\)\s*\{\s*:/i;
34
+ if (DANGER.test(args)) {
35
+ process.stderr.write('example-deny-dangerous: blocked - the arguments contain a destructive pattern.\n');
36
+ process.exit(2); // DENY - the gateway refuses to run the tool
37
+ }
38
+ process.exit(0); // allow
39
+ });
@@ -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
+ }
@@ -1,147 +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();
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();