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.
- package/README.md +235 -167
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +451 -147
- package/docs/KNOWN_BUGS.md +80 -33
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +222 -12
- package/package.json +67 -65
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -344
- package/src/core/child-reaper.js +61 -0
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -275
- package/src/core/hook-loader.js +824 -791
- package/src/core/hook-runner.js +566 -346
- package/src/core/logger.js +297 -171
- package/src/core/matcher.js +100 -51
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +119 -121
- package/src/extend/configure.md +88 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +190 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1674 -807
- package/src/mcp/expose-store.js +577 -430
- package/src/mcp/gated-run.js +210 -194
- package/src/mcp/http-transport.js +1563 -1012
- package/src/mcp/mcp-client.js +1070 -530
- package/src/mcp/modern.js +406 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +3112 -1405
- package/src/tools/registry.js +661 -513
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1187 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -1190
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +14 -3
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
package/mcp/expose.example.json
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
{
|
|
2
|
-
"_README": "SAMPLE config
|
|
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
|
|
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
|
|
27
|
-
* `tools/call
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
364
|
+
if (line.length === 0) continue; // blank separator line - ignore
|
|
155
365
|
let msg;
|
|
156
366
|
try {
|
|
157
367
|
msg = JSON.parse(line);
|