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.
- package/README.md +228 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +451 -451
- package/docs/KNOWN_BUGS.md +80 -80
- package/docs/design.md +84 -84
- 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 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +380 -380
- package/package.json +67 -67
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -353
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -329
- package/src/core/hook-loader.js +824 -824
- package/src/core/hook-runner.js +566 -566
- package/src/core/logger.js +297 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +152 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +1719 -1674
- package/src/mcp/expose-store.js +577 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1563 -1563
- package/src/mcp/mcp-client.js +1070 -1070
- package/src/mcp/modern.js +406 -406
- package/src/mcp/protocol.js +468 -468
- package/src/mcp/server.js +3112 -3112
- package/src/tools/registry.js +661 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1187 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -1616
- 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 -120
- 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 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
|
@@ -1,380 +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 - 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();
|
|
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();
|