toolfunnel 0.6.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 +11 -7
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +97 -18
- package/docs/KNOWN_BUGS.md +71 -90
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +24 -10
- package/hooks/hooks.manifest.json +1 -1
- package/hooks/scripts/example-deny-dangerous.js +12 -4
- package/mcp/servers/mock-upstream/server.js +36 -6
- package/package.json +67 -65
- package/src/auth/config.js +2 -2
- package/src/auth/resource-server.js +12 -3
- package/src/core/child-reaper.js +61 -0
- package/src/core/events.js +7 -7
- package/src/core/hook-engine.js +85 -31
- package/src/core/hook-loader.js +46 -13
- package/src/core/hook-runner.js +258 -38
- package/src/core/logger.js +138 -12
- package/src/core/matcher.js +59 -10
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +1 -1
- package/src/extend/add-hook.md +2 -2
- package/src/extend/add-mcp.md +3 -6
- package/src/extend/configure.md +2 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/howto.js +3 -3
- package/src/extend/package.md +11 -8
- package/src/mcp/aggregator.js +480 -52
- package/src/mcp/expose-store.js +130 -13
- package/src/mcp/gated-run.js +26 -17
- package/src/mcp/http-transport.js +274 -22
- package/src/mcp/mcp-client.js +194 -31
- package/src/mcp/modern.js +44 -10
- package/src/mcp/server.js +513 -89
- package/src/tools/registry.js +172 -24
- package/src/ui/public/app.js +17 -4
- package/src/ui/server.js +297 -29
- package/tools/scripts/tf-hook-set.js +11 -3
- package/tools/tools.register.json +227 -227
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
package/src/mcp/server.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* server.js - the HAND-ROLLED minimal MCP server over stdio
|
|
5
|
+
* server.js - the HAND-ROLLED minimal MCP server over stdio.
|
|
6
6
|
*
|
|
7
7
|
* Runnable as: node src/mcp/server.js
|
|
8
8
|
*
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* JSON-RPC methods handled (MCP, the subset our test client speaks):
|
|
14
14
|
* - "initialize" -> { protocolVersion, serverInfo, capabilities: { tools: {} } }
|
|
15
15
|
* - "tools/list" -> { tools: [ ...protocol.toolDefinitions() ] } (the 4 meta-tools;
|
|
16
|
-
* a clear seam is left for curated-direct tools
|
|
16
|
+
* a clear seam is left for curated-direct tools)
|
|
17
17
|
* - "tools/call" { name, arguments } -> protocol.dispatch(name, arguments), wrapped in the
|
|
18
18
|
* MCP tools/call result shape: { content:[{ type:"text", text: JSON }],
|
|
19
19
|
* isError? }. On a protocol error result we set isError:true.
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* framing on the way out keeps the transport trivial and matches "newline-delimited JSON is fine
|
|
27
27
|
* for our test client".
|
|
28
28
|
*
|
|
29
|
-
* Wiring
|
|
29
|
+
* Wiring: a real Registry (loaded from
|
|
30
30
|
* src/tools/tools.register.json - the canonical tool register), a real HookEngine over the hooks
|
|
31
31
|
* manifest, the real gatedRun, and the real howto. A small ADAPTER bridges the register's
|
|
32
32
|
* resolveExecution({type,run}) shape to the { execute, toolName, args } shape protocol.runTool
|
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
const fs = require('node:fs');
|
|
40
40
|
const path = require('node:path');
|
|
41
41
|
const { spawn } = require('node:child_process');
|
|
42
|
+
const reaper = require('../core/child-reaper');
|
|
42
43
|
|
|
43
44
|
const { makeProtocol } = require('./protocol');
|
|
44
45
|
const { gatedRun } = require('./gated-run');
|
|
45
|
-
const { Aggregator } = require('./aggregator');
|
|
46
|
+
const { Aggregator, withForwardedToolMeta } = require('./aggregator');
|
|
46
47
|
const { loadExposeStore } = require('./expose-store');
|
|
47
48
|
const { loadRegistry } = require('../tools/registry');
|
|
48
49
|
const { loadToolState, loadToolStateResult, isToolEnabled, isToolHot, getPassthrough } = require('../tools/tool-state');
|
|
@@ -73,19 +74,58 @@ const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
|
|
|
73
74
|
const TOOL_STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
|
|
74
75
|
const MANIFEST_PATH = path.join(ROOT, 'hooks', 'hooks.manifest.json');
|
|
75
76
|
const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
|
|
76
|
-
//
|
|
77
|
+
// The persisted curated-expose + upstream-MCP config. Default is EMPTY, so the
|
|
77
78
|
// aggregator connects to nothing and the curated-direct surface is empty - the server is
|
|
78
|
-
// behaviourally identical to
|
|
79
|
+
// behaviourally identical to the bare gateway until an upstream + expose entry is added.
|
|
79
80
|
const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
|
|
80
81
|
|
|
82
|
+
// Every legacy revision this server speaks, NEWEST FIRST (0.7.0). initialize echoes the
|
|
83
|
+
// requested member (2.1); an unsupported string gets LEGACY_VERSIONS[0] (lifecycle SHOULD:
|
|
84
|
+
// "the latest version supported by the server").
|
|
85
|
+
const LEGACY_VERSIONS = Object.freeze(['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']);
|
|
86
|
+
// Hard cap on JSON-RPC batch members (0.7.0) - see handleBatch. Well past any real client's
|
|
87
|
+
// batching, far short of buying unbounded gated execution with one message.
|
|
88
|
+
const MAX_BATCH_MEMBERS = 64;
|
|
89
|
+
// LOCAL shell-tool bounds (0.7.0), matching tools/registry.js's script executor: 120 s (the
|
|
90
|
+
// upstream PAYLOAD window) and a 4 MiB output ceiling. Without a clock a hanging shell tool
|
|
91
|
+
// wedged the serialised stdio chain and starved every later request, `ping` included.
|
|
92
|
+
// Test seam: the suite proves the timeout/cap BEHAVIOUR by shrinking the windows via env;
|
|
93
|
+
// production never sets them (same seam as tools/registry.js).
|
|
94
|
+
const SHELL_TIMEOUT_MS = Number(process.env.TOOLFUNNEL_SHELL_TIMEOUT_MS) > 0
|
|
95
|
+
? Number(process.env.TOOLFUNNEL_SHELL_TIMEOUT_MS) : 120000;
|
|
96
|
+
const MAX_SHELL_OUTPUT_BYTES = Number(process.env.TOOLFUNNEL_SHELL_OUTPUT_CAP) > 0
|
|
97
|
+
? Number(process.env.TOOLFUNNEL_SHELL_OUTPUT_CAP) : 4 * 1024 * 1024;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Kill a spawned shell AND its descendants, per platform: taskkill /T on Windows, the process
|
|
101
|
+
* group on POSIX (the spawn sets `detached` so the shell leads one). Killing the shell alone
|
|
102
|
+
* orphans grandchildren that keep the stdio pipes open. Best-effort; never throws.
|
|
103
|
+
* @param {import('node:child_process').ChildProcess} child
|
|
104
|
+
*/
|
|
105
|
+
function killShellTree(child) {
|
|
106
|
+
if (!child) return;
|
|
107
|
+
try {
|
|
108
|
+
if (child.pid != null && process.platform === 'win32') {
|
|
109
|
+
require('node:child_process').execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
|
|
110
|
+
} else if (child.pid != null) {
|
|
111
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch (_eg) { child.kill('SIGKILL'); }
|
|
112
|
+
} else {
|
|
113
|
+
child.kill('SIGKILL');
|
|
114
|
+
}
|
|
115
|
+
} catch (_e) {
|
|
116
|
+
try { child.kill(); } catch (_e2) { /* nothing more we can do */ }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// The FROZEN answer for an absent or non-string protocolVersion - byte-for-byte the 0.6.0
|
|
120
|
+
// behaviour (pinned frozen behaviour: absent-pass stays the oldest, not the newest).
|
|
81
121
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
82
122
|
// The 2026-07-28 ("modern") era, as a pure module - era detection, validation, result decoration,
|
|
83
123
|
// server/discover and subscriptions/listen shapes. This server is DUAL-ERA: a request carrying
|
|
84
124
|
// modern per-request _meta is served under 2026-07-28 semantics; initialize selects THIS legacy
|
|
85
|
-
// path, byte-for-byte the 0.5.0 behaviour. modern.js needs the legacy version
|
|
125
|
+
// path, byte-for-byte the 0.5.0 behaviour. modern.js needs the legacy version list only for
|
|
86
126
|
// server/discover's supportedVersions - seeded once here so it is never duplicated.
|
|
87
127
|
const modern = require('./modern');
|
|
88
|
-
modern.setLegacyVersion(
|
|
128
|
+
modern.setLegacyVersion(LEGACY_VERSIONS);
|
|
89
129
|
// Identity comes from the OPTIONAL toolfunnel.json at the root (absent -> name "toolfunnel",
|
|
90
130
|
// version from package.json - serverInfo can never drift from the released version). The config
|
|
91
131
|
// seam is what lets a wrapped MCP introduce itself as ITSELF in the initialize handshake and
|
|
@@ -161,26 +201,79 @@ function makeRegistryAdapter(registry, opts) {
|
|
|
161
201
|
cwd: ROOT,
|
|
162
202
|
env: Object.assign({}, process.env, { TOOLFUNNEL_TOOL_ARGS: JSON.stringify(args == null ? null : args) }),
|
|
163
203
|
windowsHide: true,
|
|
204
|
+
// POSIX process group so the timeout can kill the shell's descendants too; Windows
|
|
205
|
+
// uses taskkill /T inside killTree.
|
|
206
|
+
detached: process.platform !== 'win32',
|
|
164
207
|
});
|
|
165
208
|
} catch (err) {
|
|
166
209
|
reject(new Error(`shell spawn failed: ${err.message}`));
|
|
167
210
|
return;
|
|
168
211
|
}
|
|
212
|
+
reaper.track(child); // last-resort sweep if the gateway dies without its kill path
|
|
169
213
|
let stdout = '';
|
|
170
214
|
let stderr = '';
|
|
171
215
|
let settled = false;
|
|
172
|
-
|
|
173
|
-
|
|
216
|
+
// TIMED + BOUNDED (0.7.0) - the twin of the script executor's fix in tools/registry.js. A
|
|
217
|
+
// shell tool with no clock wedged the serialised stdio chain for its whole lifetime, and
|
|
218
|
+
// unbounded accumulation made a chatty tool a memory risk in the gateway process.
|
|
219
|
+
const finish = (result) => {
|
|
220
|
+
if (settled) return;
|
|
221
|
+
settled = true;
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
// Release the pipes and unref the handle - the ChildProcess itself keeps the event loop
|
|
224
|
+
// referenced until the child exits (same fix as core/hook-runner.js and tools/registry.js).
|
|
225
|
+
for (const s of [child.stdout, child.stderr]) {
|
|
226
|
+
try { if (s && typeof s.destroy === 'function') s.destroy(); } catch (_e) { /* ignore */ }
|
|
227
|
+
}
|
|
228
|
+
try { if (typeof child.unref === 'function') child.unref(); } catch (_e) { /* ignore */ }
|
|
229
|
+
reaper.untrack(child);
|
|
230
|
+
resolve(result);
|
|
231
|
+
};
|
|
232
|
+
const timer = setTimeout(() => {
|
|
233
|
+
killShellTree(child);
|
|
234
|
+
finish({
|
|
235
|
+
ok: false,
|
|
236
|
+
code: -1,
|
|
237
|
+
stdout,
|
|
238
|
+
stderr: (stderr ? stderr + '\n' : '') + `shell tool timed out after ${SHELL_TIMEOUT_MS}ms and was terminated`,
|
|
239
|
+
timedOut: true,
|
|
240
|
+
});
|
|
241
|
+
}, SHELL_TIMEOUT_MS);
|
|
242
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
243
|
+
// setEncoding so multi-byte UTF-8 split across chunks decodes correctly; count REAL bytes
|
|
244
|
+
// (String.length is UTF-16 code units - non-ASCII ran ~3x past a "byte" cap).
|
|
245
|
+
let shOutBytes = 0;
|
|
246
|
+
let shErrBytes = 0;
|
|
247
|
+
if (child.stdout) {
|
|
248
|
+
child.stdout.setEncoding('utf8');
|
|
249
|
+
child.stdout.on('data', (d) => {
|
|
250
|
+
if (shOutBytes >= MAX_SHELL_OUTPUT_BYTES) return;
|
|
251
|
+
stdout += d;
|
|
252
|
+
shOutBytes += Buffer.byteLength(d, 'utf8');
|
|
253
|
+
if (shOutBytes >= MAX_SHELL_OUTPUT_BYTES) {
|
|
254
|
+
killShellTree(child);
|
|
255
|
+
finish({ ok: false, code: -1, stdout, stderr: stderr + '\nshell tool output exceeded the cap and was terminated', outputCapped: true });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (child.stderr) {
|
|
260
|
+
child.stderr.setEncoding('utf8');
|
|
261
|
+
child.stderr.on('data', (d) => {
|
|
262
|
+
if (shErrBytes >= MAX_SHELL_OUTPUT_BYTES) return;
|
|
263
|
+
stderr += d;
|
|
264
|
+
shErrBytes += Buffer.byteLength(d, 'utf8');
|
|
265
|
+
});
|
|
266
|
+
}
|
|
174
267
|
child.on('error', (err) => {
|
|
175
268
|
if (settled) return;
|
|
176
269
|
settled = true;
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
reaper.untrack(child);
|
|
177
272
|
reject(new Error(`shell spawn failed: ${err.message}`));
|
|
178
273
|
});
|
|
179
274
|
child.on('close', (code) => {
|
|
180
|
-
if (settled) return;
|
|
181
|
-
settled = true;
|
|
182
275
|
const c = typeof code === 'number' ? code : -1;
|
|
183
|
-
|
|
276
|
+
finish({ ok: c === 0, code: c, stdout, stderr });
|
|
184
277
|
});
|
|
185
278
|
});
|
|
186
279
|
}
|
|
@@ -235,7 +328,13 @@ function makeRegistryAdapter(registry, opts) {
|
|
|
235
328
|
// Build the { execute, toolName, args, mode } resolution protocol.runTool hands to gatedRun.
|
|
236
329
|
resolveExecution(nameOrId, args) {
|
|
237
330
|
const id = resolveId(nameOrId);
|
|
331
|
+
// ENABLED means "lean-visible AND runnable" (tool-state.js) - the off-switch must switch the
|
|
332
|
+
// tool OFF, not merely hide it from the lean list (0.7.0). Read FRESH like list()
|
|
333
|
+
// so a UI toggle is live. Disabled resolves to null -> the existing clean "not runnable"
|
|
334
|
+
// error; indistinguishable from absent, which is the point.
|
|
335
|
+
const state = toolStatePath ? loadToolState(toolStatePath) : null;
|
|
238
336
|
if (id) {
|
|
337
|
+
if (state && !isToolEnabled(state, id)) return null;
|
|
239
338
|
// LOCAL tool - byte-for-byte the pre-slice-2 path. A local name that resolves but isn't
|
|
240
339
|
// runnable returns null and does NOT fall through to upstream (local-register WINS: a local
|
|
241
340
|
// name never silently resolves to a remote upstream).
|
|
@@ -272,6 +371,8 @@ function makeRegistryAdapter(registry, opts) {
|
|
|
272
371
|
// No local tool by this name - try the LEAN upstream forward. resolveLeanExecution returns a
|
|
273
372
|
// resolution whose execute thunk lazy-(re)connects + unwraps; the gate matches fwd.toolName (the
|
|
274
373
|
// surfaced name). mode is 'gateway' so it goes THROUGH gatedRun - never the reference handoff.
|
|
374
|
+
// Same enabled rule as the local branch, keyed by the SURFACED name (what the overlay stores).
|
|
375
|
+
if (state && !isToolEnabled(state, nameOrId)) return null;
|
|
275
376
|
const agg = liveAggregator();
|
|
276
377
|
if (!agg || typeof agg.resolveLeanExecution !== 'function') return null;
|
|
277
378
|
let fwd = null;
|
|
@@ -286,10 +387,10 @@ function makeRegistryAdapter(registry, opts) {
|
|
|
286
387
|
// All wiring failures are caught so the server still starts and reports a clean JSON-RPC error
|
|
287
388
|
// instead of dying - a dead server hangs the client (and the test).
|
|
288
389
|
//
|
|
289
|
-
// Returns an OBJECT, not just the protocol, because the
|
|
390
|
+
// Returns an OBJECT, not just the protocol, because the curated-direct call path needs
|
|
290
391
|
// more than the protocol:
|
|
291
392
|
// { protocol, aggregator, engine, ctx }
|
|
292
|
-
// - protocol : the 4 meta-tools (
|
|
393
|
+
// - protocol : the 4 meta-tools (the meta-tool logic, unchanged) - callers needing just the
|
|
293
394
|
// protocol read it off `.protocol` (backward compat).
|
|
294
395
|
// - aggregator : the upstream-MCP connection + curated-expose set (built from expose.json).
|
|
295
396
|
// Default expose.json is EMPTY so it connects to nothing.
|
|
@@ -342,12 +443,12 @@ function buildProtocol() {
|
|
|
342
443
|
});
|
|
343
444
|
build.engine = new HookEngine(loader, { cwd: ROOT });
|
|
344
445
|
|
|
345
|
-
//
|
|
446
|
+
// The aggregator over expose.json (EMPTY by default -> nothing connects).
|
|
346
447
|
// loadExposeStore on a missing/empty file returns an empty store; Aggregator over an empty store
|
|
347
448
|
// advertises no curated-direct tools and connectAll() is an instant no-op.
|
|
348
449
|
const store = loadExposeStore(EXPOSE_PATH);
|
|
349
450
|
build.aggregator = new Aggregator({
|
|
350
|
-
store,
|
|
451
|
+
store, gatewayRoot: ROOT,
|
|
351
452
|
// The WRAPPED upstream is exempt from the path-isolation guard (transparent-wrapper mode,
|
|
352
453
|
// by design). Fresh state read per connect, so a live wrap change counts.
|
|
353
454
|
wrapTargetProvider: () => {
|
|
@@ -412,7 +513,7 @@ function makeError(id, code, message, data) {
|
|
|
412
513
|
// A wrap must be indistinguishable from connecting to the wrapped MCP directly, so we present ITS
|
|
413
514
|
// identity verbatim - name, version, capabilities, instructions - instead of ToolFunnel's own.
|
|
414
515
|
function handleInitialize(params, wrapId) {
|
|
415
|
-
// protocolVersion + serverInfo + capabilities { tools:{} } (
|
|
516
|
+
// protocolVersion + serverInfo + capabilities { tools:{} } (
|
|
416
517
|
// mcp-server.test.js asserts a `tools` capability + a protocolVersion).
|
|
417
518
|
// Activity log (self-gating; no-op unless logging is enabled): the CLIENT-connected half of
|
|
418
519
|
// connect logging - the aggregator logs the upstream half. Wrapped though logger.log never throws.
|
|
@@ -443,11 +544,19 @@ function handleInitialize(params, wrapId) {
|
|
|
443
544
|
return out;
|
|
444
545
|
}
|
|
445
546
|
|
|
547
|
+
// NEGOTIATE (non-wrap only - the wrap branch above stays upstream-clamped): echo the
|
|
548
|
+
// requested version when we speak it; an unsupported STRING gets our latest legacy (lifecycle
|
|
549
|
+
// SHOULD - includes a requested 2026-07-28: initialize IS the legacy path); an absent or
|
|
550
|
+
// non-string request keeps the frozen 2024-11-05 answer.
|
|
551
|
+
const requested = params && typeof params.protocolVersion === 'string' ? params.protocolVersion : null;
|
|
552
|
+
const negotiated = requested === null
|
|
553
|
+
? PROTOCOL_VERSION
|
|
554
|
+
: (LEGACY_VERSIONS.includes(requested) ? requested : LEGACY_VERSIONS[0]);
|
|
446
555
|
return {
|
|
447
|
-
protocolVersion:
|
|
556
|
+
protocolVersion: negotiated,
|
|
448
557
|
serverInfo: SERVER_INFO,
|
|
449
558
|
capabilities: {
|
|
450
|
-
// We are a tool host. `tools: {}` declares the capability.
|
|
559
|
+
// We are a tool host. `tools: {}` declares the capability. The aggregator wires curated-direct
|
|
451
560
|
// hot-updates, so we now advertise listChanged:true - the server may emit
|
|
452
561
|
// notifications/tools/list_changed when the curated-direct expose set changes (see
|
|
453
562
|
// emitToolsListChanged). The mcp-server.test.js assertion only requires a `tools`
|
|
@@ -472,7 +581,7 @@ function handleToolsList(protocol, aggregator, opts) {
|
|
|
472
581
|
// Precedence is local-register-wins over an upstream of the same name (matches the lean rule), and
|
|
473
582
|
// a META-tool can NEVER be shadowed by a local/upstream tool of the same name (safety). With the
|
|
474
583
|
// default EMPTY state + EMPTY expose.json this is exactly the 4 meta-tools - byte-identical to the
|
|
475
|
-
// pre-matrix surface. `opts` is OPTIONAL: a
|
|
584
|
+
// pre-matrix surface. `opts` is OPTIONAL: a bare-protocol caller (no registry/state) gets
|
|
476
585
|
// meta-tools (all hot) + curated-direct, the prior behaviour. NEVER throws.
|
|
477
586
|
const o = opts || {};
|
|
478
587
|
const registry = o.registry || null;
|
|
@@ -520,12 +629,16 @@ function handleToolsList(protocol, aggregator, opts) {
|
|
|
520
629
|
}
|
|
521
630
|
}
|
|
522
631
|
|
|
523
|
-
// 3. CURATED-DIRECT (expose[]) -
|
|
524
|
-
//
|
|
632
|
+
// 3. CURATED-DIRECT (expose[]) - AND enabled: a state-disabled tool refuses to run,
|
|
633
|
+
// so advertising it would put a phantom uncallable tool on the surface. exposedToolDefinitions
|
|
634
|
+
// never throws, but guard anyway. Skip any def colliding with a RESERVED meta name, OR with a
|
|
635
|
+
// name a LOCAL register tool owns (id or display name) - the same guard block 4 applies below:
|
|
636
|
+
// the advertised name must resolve to what actually runs, and a local-register collision would
|
|
637
|
+
// otherwise advertise the upstream's schema while the call resolves to the local tool.
|
|
525
638
|
if (aggregator && typeof aggregator.exposedToolDefinitions === 'function') {
|
|
526
639
|
try {
|
|
527
640
|
const defs = aggregator.exposedToolDefinitions();
|
|
528
|
-
if (Array.isArray(defs)) for (const def of defs) if (def && !byName.has(def.name) && !metaNames.has(def.name)) byName.set(def.name, def);
|
|
641
|
+
if (Array.isArray(defs)) for (const def of defs) if (def && !byName.has(def.name) && !metaNames.has(def.name) && !localNameTaken(registry, def.name) && isToolEnabled(state, def.name)) byName.set(def.name, def);
|
|
529
642
|
} catch (err) {
|
|
530
643
|
logErr('exposedToolDefinitions failed:', (err && err.message) || String(err));
|
|
531
644
|
}
|
|
@@ -537,10 +650,26 @@ function handleToolsList(protocol, aggregator, opts) {
|
|
|
537
650
|
// (a local display-name collision would otherwise advertise the upstream but run the local tool).
|
|
538
651
|
if (aggregator && typeof aggregator.leanToolDefinitions === 'function') {
|
|
539
652
|
try {
|
|
540
|
-
|
|
653
|
+
// 6.2: iterate hot-upstream tools in STORE order (expose.json's upstream sequence), not
|
|
654
|
+
// connection-map order - a death/recovery re-insertion re-orders the map and would
|
|
655
|
+
// silently shuffle the advertised surface between restarts. Scoped HERE only: the wrap's
|
|
656
|
+
// passthroughDefinitions walks leanToolDefinitions itself and must stay untouched.
|
|
657
|
+
const leanDefs = aggregator.leanToolDefinitions();
|
|
658
|
+
const order = typeof aggregator.upstreamOrder === 'function' ? aggregator.upstreamOrder() : [];
|
|
659
|
+
const rank = new Map(order.map((id, i) => [id, i]));
|
|
660
|
+
const stable = leanDefs.map((d, i) => ({ d, i }));
|
|
661
|
+
stable.sort((a, b) => {
|
|
662
|
+
const ra = rank.has(a.d && a.d.upstream) ? rank.get(a.d.upstream) : order.length;
|
|
663
|
+
const rb = rank.has(b.d && b.d.upstream) ? rank.get(b.d.upstream) : order.length;
|
|
664
|
+
return ra !== rb ? ra - rb : a.i - b.i; // store order first; original order within an upstream
|
|
665
|
+
});
|
|
666
|
+
for (const { d } of stable) {
|
|
541
667
|
if (!d || byName.has(d.name) || metaNames.has(d.name) || localNameTaken(registry, d.name)) continue;
|
|
542
668
|
if (isToolHot(state, d.name, false) && isToolEnabled(state, d.name)) {
|
|
543
|
-
|
|
669
|
+
// 3.7: the same selective-add as the aggregator's builders - the hot projection must
|
|
670
|
+
// never re-strip what the lean def now carries (nor leak its routing ids via spread).
|
|
671
|
+
byName.set(d.name, withForwardedToolMeta(
|
|
672
|
+
{ name: d.name, description: d.description || '', inputSchema: d.inputSchema || { type: 'object' } }, d));
|
|
544
673
|
}
|
|
545
674
|
}
|
|
546
675
|
} catch (err) {
|
|
@@ -594,7 +723,7 @@ function passthroughDefinitions(aggregator, state, upstreamId) {
|
|
|
594
723
|
// The upstream's RAW discovered defs - a wrapped surface must advertise the tool exactly as
|
|
595
724
|
// the upstream does (annotations, title, outputSchema, future fields). The old projection to
|
|
596
725
|
// {name, description, inputSchema} made all 13 real server-everything tools differ from a
|
|
597
|
-
// direct connection (wrap
|
|
726
|
+
// direct connection (found wire-testing the wrap, 2026-07-17). leanToolDefinitions still drives the walk: it
|
|
598
727
|
// carries the SURFACED name (the stable enabled:false key) and the collision guard.
|
|
599
728
|
const rawByName = new Map();
|
|
600
729
|
if (typeof aggregator.toolsByUpstream === 'function') {
|
|
@@ -719,7 +848,7 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
|
|
|
719
848
|
};
|
|
720
849
|
}
|
|
721
850
|
|
|
722
|
-
// ── Routing (
|
|
851
|
+
// ── Routing (the curated-direct invariant + the MATRIX) ─────────────────────────────────────
|
|
723
852
|
// 1. A meta-tool name -> protocol.dispatch (unchanged; a meta-tool can NEVER be shadowed).
|
|
724
853
|
// 2. ELSE a LOCAL tool promoted HOT, called directly -> run it exactly like toolfunnel_run_tool
|
|
725
854
|
// {name,args}: ONE path, reusing the PreToolUse gate + reference-mode + register resolution.
|
|
@@ -746,7 +875,7 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
|
|
|
746
875
|
// wrap must run the upstream regardless of a local shadow; runLeanForward resolves straight off
|
|
747
876
|
// the aggregator, so the gate still fires on the surfaced name and the upstream always runs.
|
|
748
877
|
const passthroughId = getPassthrough(state);
|
|
749
|
-
//
|
|
878
|
+
// Elicitation-bridge RESUME: an MRTR retry carries requestState (+ inputResponses) - answer the
|
|
750
879
|
// upstream's held question and continue the SUSPENDED call. It never re-enters the gate:
|
|
751
880
|
// the gate fired when the original call started, and no new upstream call is issued.
|
|
752
881
|
// Checked BEFORE the passthrough guard: a retry after the wrap was CLEARED mid-suspension
|
|
@@ -755,7 +884,8 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
|
|
|
755
884
|
// leaking an "Unknown meta-tool" funnel tell mid-conversation. requestState
|
|
756
885
|
// is protocol-level MRTR vocabulary, never ordinary tool args, so this intercept is safe.
|
|
757
886
|
if (p.requestState !== undefined || p.inputResponses !== undefined) {
|
|
758
|
-
return await resumeElicit(p, d0.modernCaller === true, passthroughId, d0.cancelKey || null
|
|
887
|
+
return await resumeElicit(p, d0.modernCaller === true, passthroughId, d0.cancelKey || null,
|
|
888
|
+
d0.clientCapabilities || null);
|
|
759
889
|
}
|
|
760
890
|
if (passthroughId) {
|
|
761
891
|
// The client calls the ORIGINAL tool name (as advertised); resolve it to the SURFACED name the
|
|
@@ -767,7 +897,8 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
|
|
|
767
897
|
// for every wrapped call.
|
|
768
898
|
const meta = stripProtocolMeta(p._meta);
|
|
769
899
|
return await runLeanForward(aggregator, { engine: d0.engine, ctx: d0.ctx }, surfaced, args,
|
|
770
|
-
{ calledName: name, meta, cancelKey: d0.cancelKey || null, modernCaller: d0.modernCaller === true
|
|
900
|
+
{ calledName: name, meta, cancelKey: d0.cancelKey || null, modernCaller: d0.modernCaller === true,
|
|
901
|
+
clientCapabilities: d0.clientCapabilities || null });
|
|
771
902
|
}
|
|
772
903
|
return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
|
|
773
904
|
}
|
|
@@ -790,6 +921,12 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
|
|
|
790
921
|
}
|
|
791
922
|
|
|
792
923
|
if (aggregator && typeof aggregator.isExposed === 'function' && aggregator.isExposed(name)) {
|
|
924
|
+
// enabled:false must actually switch the tool OFF (tool-state contract: "AND runnable" -
|
|
925
|
+
// 0.7.0): the same clean unknown-tool error as every other not-on-the-surface call,
|
|
926
|
+
// so a disabled tool is indistinguishable from an absent one.
|
|
927
|
+
if (!isToolEnabled(state, name)) {
|
|
928
|
+
return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
|
|
929
|
+
}
|
|
793
930
|
// The caller's non-protocol _meta rides the forward here too - the progressToken is the
|
|
794
931
|
// upstream-side keep-alive (beats are NOT fanned out to funnel clients; wrap mode relays them).
|
|
795
932
|
return await runCuratedDirect(aggregator, deps, name, args, stripProtocolMeta(p._meta));
|
|
@@ -877,7 +1014,10 @@ function stripProtocolMeta(m) {
|
|
|
877
1014
|
if (!m || typeof m !== 'object' || Array.isArray(m)) return undefined;
|
|
878
1015
|
const out = {};
|
|
879
1016
|
for (const k of Object.keys(m)) {
|
|
880
|
-
|
|
1017
|
+
// logLevel is EXEMPT (1.4): the caller's severity choice must survive to the upstream hop -
|
|
1018
|
+
// the McpClient owns the era boundary (modern: forwarded in _meta; legacy: translated to
|
|
1019
|
+
// logging/setLevel and never leaked). Every other protocol key is ours to strip.
|
|
1020
|
+
if (!k.startsWith('io.modelcontextprotocol/') || k === modern.META_KEYS.LOG_LEVEL) out[k] = m[k];
|
|
881
1021
|
}
|
|
882
1022
|
return Object.keys(out).length ? out : undefined;
|
|
883
1023
|
}
|
|
@@ -929,7 +1069,7 @@ async function runCuratedDirect(aggregator, deps, name, args, meta) {
|
|
|
929
1069
|
result && result.reason,
|
|
930
1070
|
'curated tool call failed'
|
|
931
1071
|
);
|
|
932
|
-
return { content: [{ type: 'text', text: stringifyContent(message) }], isError: true };
|
|
1072
|
+
return { content: [{ type: 'text', text: stringifyContent(withUpstreamData(message, result)) }], isError: true };
|
|
933
1073
|
}
|
|
934
1074
|
|
|
935
1075
|
// Success: result.output IS the upstream's own tools/call envelope ({ content, isError }).
|
|
@@ -944,6 +1084,11 @@ async function runCuratedDirect(aggregator, deps, name, args, meta) {
|
|
|
944
1084
|
// silently stripped modern upstreams' structured results on the curated-direct path while
|
|
945
1085
|
// the wrap path returned them verbatim.
|
|
946
1086
|
if (out.structuredContent !== undefined) shaped.structuredContent = out.structuredContent;
|
|
1087
|
+
// Result _meta forwards MINUS the protocol-identity keys (3.10, funnel half): an upstream's
|
|
1088
|
+
// io.modelcontextprotocol/* result keys (serverInfo!) would misidentify this gateway; app
|
|
1089
|
+
// keys are the client's business and ride through. Same rule as the request-side strip.
|
|
1090
|
+
const fm = stripProtocolMeta(out._meta);
|
|
1091
|
+
if (fm !== undefined) shaped._meta = fm;
|
|
947
1092
|
return shaped;
|
|
948
1093
|
}
|
|
949
1094
|
// Defensive fallback: a non-standard upstream payload - wrap it as text so the client still gets
|
|
@@ -998,14 +1143,16 @@ async function runHotUpstreamDirect(aggregator, deps, name, args, meta) {
|
|
|
998
1143
|
result && result.reason,
|
|
999
1144
|
'tool call failed'
|
|
1000
1145
|
);
|
|
1001
|
-
return { content: [{ type: 'text', text: stringifyContent(message) }], isError: true };
|
|
1146
|
+
return { content: [{ type: 'text', text: stringifyContent(withUpstreamData(message, result)) }], isError: true };
|
|
1002
1147
|
}
|
|
1003
1148
|
// Success: result.output IS the upstream's own tools/call envelope - pass it through, with
|
|
1004
|
-
// structuredContent riding along (same shaping as runCuratedDirect).
|
|
1149
|
+
// structuredContent + app-key result _meta riding along (same shaping as runCuratedDirect).
|
|
1005
1150
|
const out = result.output;
|
|
1006
1151
|
if (out && typeof out === 'object' && Array.isArray(out.content)) {
|
|
1007
1152
|
const shaped = { content: out.content, isError: out.isError === true };
|
|
1008
1153
|
if (out.structuredContent !== undefined) shaped.structuredContent = out.structuredContent;
|
|
1154
|
+
const fm = stripProtocolMeta(out._meta);
|
|
1155
|
+
if (fm !== undefined) shaped._meta = fm;
|
|
1009
1156
|
return shaped;
|
|
1010
1157
|
}
|
|
1011
1158
|
// Defensive fallback: a non-standard upstream payload still returns a well-formed envelope.
|
|
@@ -1043,7 +1190,7 @@ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
|
|
|
1043
1190
|
// forwardWrapped - a wrapped tools/call is the DOMINANT long-running-cancel case and was the
|
|
1044
1191
|
// one hole left in the map. Registered post-gate: a cancel landing during the gate phase is a
|
|
1045
1192
|
// best-effort drop (micro-window, same accepted class as the rpc-issue microtask window).
|
|
1046
|
-
// The rpc id is ALSO captured locally:
|
|
1193
|
+
// The rpc id is ALSO captured locally: an elicitation-bridge suspension must extend that request's
|
|
1047
1194
|
// timeout (a human answers at human speed; the dead-upstream clock must not kill the hold).
|
|
1048
1195
|
let upstreamRpcId = null;
|
|
1049
1196
|
const track = {
|
|
@@ -1061,7 +1208,7 @@ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
|
|
|
1061
1208
|
if (!resolution || typeof resolution.execute !== 'function') {
|
|
1062
1209
|
return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
|
|
1063
1210
|
}
|
|
1064
|
-
//
|
|
1211
|
+
// Elicitation bridge: the upstream may send `elicitation/create` mid-call and hold the call open. Race
|
|
1065
1212
|
// the real forward against an elicit INSIDE the gated execute, so the gate + PostToolUse fire
|
|
1066
1213
|
// exactly once around whichever wins. An elicit win resolves gatedRun with a sentinel; the real
|
|
1067
1214
|
// upstream promise keeps running and is either suspended (modern caller -> MRTR) or completed
|
|
@@ -1097,17 +1244,24 @@ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
|
|
|
1097
1244
|
}
|
|
1098
1245
|
if (result && result.ok === true && result.output && result.output.__tfElicit && elicitCaught) {
|
|
1099
1246
|
const elicit = elicitCaught;
|
|
1100
|
-
|
|
1247
|
+
// 5.1/5.2: suspension requires a modern caller that DECLARED the matching elicitation
|
|
1248
|
+
// capability on THIS request. Everyone else - legacy, capability-less modern, form-only
|
|
1249
|
+
// modern facing a url-mode question - takes the existing auto-decline branch (no new
|
|
1250
|
+
// error channel; handleToolsCall is shared with the frozen legacy path).
|
|
1251
|
+
if (o.modernCaller === true && callerAcceptsElicit(o.clientCapabilities, elicit.params)) {
|
|
1101
1252
|
return suspendForElicit(elicit, realPromise, resolution.upstream, called, upstreamRpcId);
|
|
1102
1253
|
}
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
logErr(`bridge-b:
|
|
1254
|
+
// The caller cannot receive this backwards request - DECLINE so the upstream's held call
|
|
1255
|
+
// completes, then serve its actual outcome.
|
|
1256
|
+
logErr(`bridge-b: caller cannot receive this elicitation (legacy, or capability not declared) - auto-declined for "${called}"`);
|
|
1106
1257
|
try { elicit.client.respondToServer(elicit.elicitId, { action: 'decline' }); } catch (_e) { /* ignore */ }
|
|
1107
1258
|
try {
|
|
1108
1259
|
const out = await realPromise;
|
|
1109
1260
|
result = { ok: true, blocked: false, output: out };
|
|
1110
|
-
} catch (
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
// The declined-elicit completion can ALSO end in a raw upstream JSON-RPC error - relay it
|
|
1263
|
+
// with the same fidelity as the direct path.
|
|
1264
|
+
if (err && err.rpcError) return rpcErrorSentinel(err.rpcError);
|
|
1111
1265
|
return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
|
|
1112
1266
|
}
|
|
1113
1267
|
}
|
|
@@ -1117,6 +1271,12 @@ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
|
|
|
1117
1271
|
Array.isArray(result.output.content)) {
|
|
1118
1272
|
return result.output;
|
|
1119
1273
|
}
|
|
1274
|
+
// A raw JSON-RPC error from the wrapped upstream relays VERBATIM: McpClient preserved
|
|
1275
|
+
// it as error.rpcError; ride the sentinel to the dispatch seam, where the caller's era shapes
|
|
1276
|
+
// the wire error. (A gate block never carries error - the guard is belt-and-braces.)
|
|
1277
|
+
if (result && result.ok !== true && result.blocked !== true && result.error && result.error.rpcError) {
|
|
1278
|
+
return rpcErrorSentinel(result.error.rpcError);
|
|
1279
|
+
}
|
|
1120
1280
|
// A gate BLOCK carries its reason ONLY when it is deliberate operator policy. An INTERNAL
|
|
1121
1281
|
// fail-closed (engine wiring/crash - gatedRun marks it internal:true) holds gateway strings:
|
|
1122
1282
|
// neutralise those too.
|
|
@@ -1128,6 +1288,50 @@ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
|
|
|
1128
1288
|
return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
|
|
1129
1289
|
}
|
|
1130
1290
|
|
|
1291
|
+
/**
|
|
1292
|
+
* The wrap error-relay sentinel (0.7.0). A raw JSON-RPC error from the WRAPPED upstream
|
|
1293
|
+
* must reach the caller as a JSON-RPC ERROR (README: identity, tools, results, ERRORS,
|
|
1294
|
+
* notifications - byte-for-byte), never a flattened `tool "X" failed` result. The tools/call
|
|
1295
|
+
* handlers return result ENVELOPES, so the verbatim error rides this sentinel to the dispatch
|
|
1296
|
+
* seams (legacy + modern), where relayedRpcError mints the era-correct wire error.
|
|
1297
|
+
*/
|
|
1298
|
+
function rpcErrorSentinel(rpcError) {
|
|
1299
|
+
const e = rpcError || {};
|
|
1300
|
+
return { __tfRpcError: { code: e.code, message: e.message, data: e.data } };
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Mint the wire JSON-RPC error for a relayed upstream error, era-shaped:
|
|
1305
|
+
* - LEGACY caller: VERBATIM - code + message + data byte-for-byte (the wrap promise).
|
|
1306
|
+
* - MODERN caller: 2026-07-28's error-code allocation policy (base protocol, Error Handling)
|
|
1307
|
+
* forbids emitting implementation-band codes it does not define, and names -32602 as -32002's
|
|
1308
|
+
* replacement. Band codes are re-minted (-32002 -> -32602, all others -> internal) with the
|
|
1309
|
+
* original preserved VERBATIM under data.upstreamCode / data.upstreamData. Standard JSON-RPC
|
|
1310
|
+
* codes and application-space codes relay untouched in both eras.
|
|
1311
|
+
*/
|
|
1312
|
+
function relayedRpcError(id, sentinel, modernCaller) {
|
|
1313
|
+
const e = (sentinel && sentinel.__tfRpcError) || {};
|
|
1314
|
+
if (!modernCaller) return makeError(id, e.code, e.message, e.data);
|
|
1315
|
+
const preserved = e.data !== undefined
|
|
1316
|
+
? { upstreamCode: e.code, upstreamData: e.data }
|
|
1317
|
+
: { upstreamCode: e.code };
|
|
1318
|
+
if (e.code === -32002) return makeError(id, -32602, e.message, preserved);
|
|
1319
|
+
if (typeof e.code === 'number' && e.code >= -32099 && e.code <= -32000) {
|
|
1320
|
+
return makeError(id, ERR.INTERNAL, e.message, preserved);
|
|
1321
|
+
}
|
|
1322
|
+
return makeError(id, e.code, e.message, e.data);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/** Append relayed upstream error DATA to a funnel error message (the funnel half: the
|
|
1326
|
+
* message + code already survive via McpClient's text; the structured data was the lost third).
|
|
1327
|
+
* Wire-parsed data has no cycles, but stay defensive - shaping must never throw. */
|
|
1328
|
+
function withUpstreamData(message, result) {
|
|
1329
|
+
const e = result && result.error && result.error.rpcError;
|
|
1330
|
+
if (!e || e.data === undefined) return message;
|
|
1331
|
+
try { return `${message} | upstream error data: ${JSON.stringify(e.data)}`; }
|
|
1332
|
+
catch (_e) { return message; }
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1131
1335
|
/**
|
|
1132
1336
|
* Wrap a protocol dispatch result ({ ok, output, error?, reason? }) in the MCP tools/call
|
|
1133
1337
|
* envelope. Extracted so the meta-tool path and the unknown-tool path share one shaping.
|
|
@@ -1146,6 +1350,8 @@ function wrapProtocolResult(result) {
|
|
|
1146
1350
|
result && result.reason,
|
|
1147
1351
|
'tool call failed'
|
|
1148
1352
|
);
|
|
1353
|
+
// Same data preservation as the curated/hot paths - covers the lean run_tool route.
|
|
1354
|
+
payload = withUpstreamData(payload, result);
|
|
1149
1355
|
} else if (result && result.output !== undefined) {
|
|
1150
1356
|
payload = result.output;
|
|
1151
1357
|
} else if (result && result.mode === 'reference') {
|
|
@@ -1222,9 +1428,9 @@ function renderUpstreamInstructions(def) {
|
|
|
1222
1428
|
}
|
|
1223
1429
|
|
|
1224
1430
|
/**
|
|
1225
|
-
* Normalise the first argument of handleMessage / createStdioLoop into the canonical
|
|
1431
|
+
* Normalise the first argument of handleMessage / createStdioLoop into the canonical full-build
|
|
1226
1432
|
* build shape { protocol, aggregator, engine, ctx }. Backward compat: a caller may still pass a
|
|
1227
|
-
* bare protocol (
|
|
1433
|
+
* bare protocol (older tests) - we wrap it with a null aggregator + no deps so
|
|
1228
1434
|
* the meta-tool path is identical and the curated-direct path is simply never taken (isExposed
|
|
1229
1435
|
* is unreachable without an aggregator). A protocol is detected by its dispatch() method.
|
|
1230
1436
|
* @param {object} arg either { protocol, aggregator, engine, ctx } or a bare protocol
|
|
@@ -1233,7 +1439,7 @@ function renderUpstreamInstructions(def) {
|
|
|
1233
1439
|
function normaliseBuild(arg) {
|
|
1234
1440
|
const a = arg || {};
|
|
1235
1441
|
if (a && typeof a.dispatch === 'function') {
|
|
1236
|
-
// It's a bare protocol -
|
|
1442
|
+
// It's a bare protocol - the meta-only shape (no register/state -> no hot promotion; meta + curated only).
|
|
1237
1443
|
return { protocol: a, aggregator: null, engine: null, ctx: null, registry: null, toolStatePath: null };
|
|
1238
1444
|
}
|
|
1239
1445
|
return {
|
|
@@ -1252,11 +1458,48 @@ function normaliseBuild(arg) {
|
|
|
1252
1458
|
// Dispatch ONE parsed JSON-RPC message. Returns a response object to send, or null for
|
|
1253
1459
|
// notifications / messages that take no reply. NEVER throws.
|
|
1254
1460
|
//
|
|
1255
|
-
// `build` is the
|
|
1461
|
+
// `build` is the full build object { protocol, aggregator, engine, ctx } (a bare protocol is
|
|
1256
1462
|
// still accepted for backward compat - see normaliseBuild). The aggregator + engine + ctx are
|
|
1257
1463
|
// threaded into tools/list and tools/call so the curated-direct surface is advertised and runs
|
|
1258
1464
|
// THROUGH the PreToolUse gate.
|
|
1259
1465
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
1466
|
+
/**
|
|
1467
|
+
* handleBatch - process a JSON-RPC batch (0.7.0). Batching exists ONLY in the
|
|
1468
|
+
* 2025-03-26 revision (added there, removed again in 2025-06-18), so the CALLER owns the gate:
|
|
1469
|
+
* stdio admits a batch iff the session negotiated 2025-03-26 (build.negotiatedLegacyVersion);
|
|
1470
|
+
* HTTP admits on header discipline (absent-or-2025-03-26 MCP-Protocol-Version - the header
|
|
1471
|
+
* itself postdates the batching revision). Members run SEQUENTIALLY in arrival order through
|
|
1472
|
+
* handleMessage - the same ordering guarantee single messages get; a malformed member earns its
|
|
1473
|
+
* own -32600 entry (handleMessage already answers that shape per member).
|
|
1474
|
+
* @returns {Promise<object[]|object|null>} the response array; null when every member was a
|
|
1475
|
+
* notification (transport answers nothing / 202); or the single JSON-RPC 2.0 -32600 object
|
|
1476
|
+
* for an EMPTY batch.
|
|
1477
|
+
*/
|
|
1478
|
+
async function handleBatch(build, arr, connKey) {
|
|
1479
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
1480
|
+
return makeError(null, ERR.INVALID_REQUEST, 'Invalid Request: empty batch');
|
|
1481
|
+
}
|
|
1482
|
+
// Enforced HERE so BOTH transports are covered by one rule (0.7.0):
|
|
1483
|
+
// - a MODERN-shaped member is illegal in a batch at all (batching is 2025-03-26 only, which
|
|
1484
|
+
// predates the modern era), and admitting one skipped the modern header contract entirely;
|
|
1485
|
+
// - members are executed SEQUENTIALLY with a gated tool call possible in every slot, so an
|
|
1486
|
+
// uncapped array is unbounded work bought with one message.
|
|
1487
|
+
if (arr.length > MAX_BATCH_MEMBERS) {
|
|
1488
|
+
return makeError(null, ERR.INVALID_REQUEST,
|
|
1489
|
+
`Invalid Request: batch too large (${arr.length} members, max ${MAX_BATCH_MEMBERS})`);
|
|
1490
|
+
}
|
|
1491
|
+
if (arr.some((m) => modern.isModernRequest(m))) {
|
|
1492
|
+
return makeError(null, ERR.INVALID_REQUEST,
|
|
1493
|
+
'Invalid Request: modern-era messages cannot be batched (JSON-RPC batching is 2025-03-26 only)');
|
|
1494
|
+
}
|
|
1495
|
+
const out = [];
|
|
1496
|
+
for (const member of arr) {
|
|
1497
|
+
const r = await handleMessage(build, member, connKey);
|
|
1498
|
+
if (r != null) out.push(r);
|
|
1499
|
+
}
|
|
1500
|
+
return out.length ? out : null;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1260
1503
|
async function handleMessage(build, msg, connKey) {
|
|
1261
1504
|
// connKey: OPTIONAL stable per-connection identity from the transport. stdio (one client per
|
|
1262
1505
|
// process) passes a constant; HTTP passes nothing - each POST is a fresh connection, so no
|
|
@@ -1286,7 +1529,14 @@ async function handleMessage(build, msg, connKey) {
|
|
|
1286
1529
|
const text = method === 'initialize'
|
|
1287
1530
|
? 'modern-only gateway (serveLegacy:false): the legacy initialize handshake is disabled - connect with the 2026-07-28 protocol (per-request _meta)'
|
|
1288
1531
|
: 'modern-only gateway (serveLegacy:false): legacy-era requests are refused - send the 2026-07-28 per-request _meta';
|
|
1289
|
-
|
|
1532
|
+
// -32601, not -32020: the allocation policy pins -32020 to HeaderMismatch and no
|
|
1533
|
+
// header is involved here. A TRUE modern-only server has no legacy methods, so method-not-
|
|
1534
|
+
// found is the recognised shape a probing legacy client already understands (the spec's own
|
|
1535
|
+
// compatibility matrix row). The message keeps naming the policy + version (versioning
|
|
1536
|
+
// SHOULD); data.policy is the machine marker the HTTP transport's NARROW 404 remap keys on -
|
|
1537
|
+
// this refusal only, never ordinary -32601s. The mint stays transport-agnostic (stdio
|
|
1538
|
+
// reaches this same site and simply has no status to remap).
|
|
1539
|
+
return makeError(id, ERR.METHOD_NOT_FOUND, text, { policy: 'modern-only' });
|
|
1290
1540
|
}
|
|
1291
1541
|
|
|
1292
1542
|
// A message with no method but with an id is a response/garbage to us - acknowledge nothing.
|
|
@@ -1402,7 +1652,7 @@ async function handleMessage(build, msg, connKey) {
|
|
|
1402
1652
|
if (!presented || presented.name !== wrapMirrorClientInfo.name ||
|
|
1403
1653
|
presented.version !== wrapMirrorClientInfo.version) {
|
|
1404
1654
|
// reconnect() settles any in-flight connect before discarding - never join a
|
|
1405
|
-
// connect this path just
|
|
1655
|
+
// connect this path just corrupted.
|
|
1406
1656
|
await aggregator.reconnect(ptId);
|
|
1407
1657
|
}
|
|
1408
1658
|
// The (re)connect refreshed the upstream identity - recompute wrapId from it.
|
|
@@ -1412,7 +1662,13 @@ async function handleMessage(build, msg, connKey) {
|
|
|
1412
1662
|
} catch (_e) { /* mirror is best-effort - wrapId (cached identity) still answers */ }
|
|
1413
1663
|
}
|
|
1414
1664
|
}
|
|
1415
|
-
|
|
1665
|
+
const initResult = handleInitialize(msg.params, wrapId);
|
|
1666
|
+
// STORE what we answered (for the stdio batching gate: batching is legal
|
|
1667
|
+
// only on a negotiated 2025-03-26 session). stdio = one client per process, so one slot
|
|
1668
|
+
// is exact; HTTP is sessionless and must use header discipline instead - it never reads
|
|
1669
|
+
// this. Stored under wrap too: the clamped answer is still what THIS session speaks.
|
|
1670
|
+
if (build && typeof build === 'object') build.negotiatedLegacyVersion = initResult.protocolVersion;
|
|
1671
|
+
return makeResult(id, initResult);
|
|
1416
1672
|
}
|
|
1417
1673
|
|
|
1418
1674
|
case 'initialized':
|
|
@@ -1421,6 +1677,17 @@ async function handleMessage(build, msg, connKey) {
|
|
|
1421
1677
|
return null;
|
|
1422
1678
|
|
|
1423
1679
|
case 'tools/list':
|
|
1680
|
+
// 6.1, era-scoped: an unrecognised cursor answers -32602 on every cursor-AWARE revision
|
|
1681
|
+
// (2025-03-26+; our list is single-page so ANY present cursor is unknown). ONLY the
|
|
1682
|
+
// exempt 2024-11-05 keeps the frozen ignore. Scoped to STDIO sessions - the negotiated
|
|
1683
|
+
// version is a true session fact there; HTTP is sessionless and keeps the frozen ignore
|
|
1684
|
+
// for legacy bodies (documented approximation; modern bodies enforce on their own path).
|
|
1685
|
+
if (connKey === 'stdio' && msg.params && msg.params.cursor !== undefined &&
|
|
1686
|
+
build && typeof build.negotiatedLegacyVersion === 'string' &&
|
|
1687
|
+
build.negotiatedLegacyVersion !== PROTOCOL_VERSION) {
|
|
1688
|
+
return makeError(id, ERR.INVALID_PARAMS,
|
|
1689
|
+
`Invalid params: unknown cursor "${String(msg.params.cursor)}" (this tools/list is single-page)`);
|
|
1690
|
+
}
|
|
1424
1691
|
return makeResult(id, handleToolsList(protocol, aggregator, { registry, toolStatePath }));
|
|
1425
1692
|
|
|
1426
1693
|
case 'tools/call': {
|
|
@@ -1428,11 +1695,16 @@ async function handleMessage(build, msg, connKey) {
|
|
|
1428
1695
|
// translation, same (connection, client id) contract as forwardWrapped.
|
|
1429
1696
|
const cancelKey = (connKey !== undefined && hasId) ? `${connKey}:${id}` : null;
|
|
1430
1697
|
const result = await handleToolsCall(protocol, aggregator,
|
|
1431
|
-
|
|
1698
|
+
// clientCapabilities EXPLICITLY null (5.1): a legacy caller declares nothing
|
|
1699
|
+
// per-request and can never receive a backwards request.
|
|
1700
|
+
{ engine, ctx, registry, toolStatePath, cancelKey, modernCaller: false, clientCapabilities: null }, msg.params);
|
|
1432
1701
|
// Observability (in-memory counters; never throws). Count every tools/call by name + whether
|
|
1433
1702
|
// it errored (an isError envelope = a tool failure OR a PreToolUse denial). Single chokepoint
|
|
1434
1703
|
// -> covers stdio AND the HTTP transport, which both route through handleMessage.
|
|
1435
|
-
metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && result.isError === true) });
|
|
1704
|
+
metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && (result.isError === true || result.__tfRpcError)) });
|
|
1705
|
+
// Relayed upstream error: a LEGACY caller gets the wrapped upstream's own
|
|
1706
|
+
// JSON-RPC error VERBATIM - code, message, data.
|
|
1707
|
+
if (result && result.__tfRpcError) return relayedRpcError(id, result, false);
|
|
1436
1708
|
return makeResult(id, result);
|
|
1437
1709
|
}
|
|
1438
1710
|
|
|
@@ -1532,14 +1804,14 @@ function clientInfoFor(upstreamId) {
|
|
|
1532
1804
|
}
|
|
1533
1805
|
|
|
1534
1806
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
1535
|
-
// BRIDGE
|
|
1807
|
+
// THE ELICITATION BRIDGE - legacy elicitation ↔ modern MRTR (the stateful adapter).
|
|
1536
1808
|
//
|
|
1537
1809
|
// A legacy upstream may send a SERVER-INITIATED `elicitation/create` mid-tool-call and hold the
|
|
1538
1810
|
// call open until it is answered. A modern client cannot receive backwards requests (stateless
|
|
1539
1811
|
// era): instead its tools/call RETURNS `resultType:"input_required"` with the question(s) and an
|
|
1540
1812
|
// opaque `requestState` token, and the client RETRIES the call with `inputResponses` + the token.
|
|
1541
1813
|
// ToolFunnel holds the suspended legacy call in the middle. Design + verified MRTR schema:
|
|
1542
|
-
//
|
|
1814
|
+
// the 0.6.0 design notes (spec draft/basic/patterns/mrtr).
|
|
1543
1815
|
//
|
|
1544
1816
|
// Correlation (the id-translation discipline from day one): a legacy
|
|
1545
1817
|
// elicit carries NO originating-call marker, so it can only be bound to an in-flight wrapped call
|
|
@@ -1654,7 +1926,7 @@ async function raceElicit(upstreamId, promise) {
|
|
|
1654
1926
|
/** Resume a suspended elicitation (the MRTR retry): answer the upstream's held question with the
|
|
1655
1927
|
* client's inputResponse, then await the held call - which may elicit AGAIN (multi-round) or
|
|
1656
1928
|
* finish. Token misses get a clean, neutral error. NEVER throws. */
|
|
1657
|
-
async function resumeElicit(params, modernCaller, currentPtId, cancelKey) {
|
|
1929
|
+
async function resumeElicit(params, modernCaller, currentPtId, cancelKey, clientCapabilities) {
|
|
1658
1930
|
const p = params || {};
|
|
1659
1931
|
const token = typeof p.requestState === 'string' ? p.requestState : null;
|
|
1660
1932
|
const entry = token && modernCaller === true ? pendingElicits.get(token) : null;
|
|
@@ -1696,7 +1968,19 @@ async function resumeElicit(params, modernCaller, currentPtId, cancelKey) {
|
|
|
1696
1968
|
if (cancelKey) inflightForwards.delete(cancelKey); // settled (or re-suspended) - retry cancel window over
|
|
1697
1969
|
}
|
|
1698
1970
|
if (winner.kind === 'elicit') {
|
|
1699
|
-
|
|
1971
|
+
// The :1699 edge, DECIDED (5.2): each modern request stands alone - the RETRY completes the
|
|
1972
|
+
// exchange it carries a token for regardless, but a FURTHER question requires the RETRY's
|
|
1973
|
+
// own declared capability. Undeclared -> the same auto-decline as the first-call path.
|
|
1974
|
+
if (callerAcceptsElicit(clientCapabilities, winner.value.params)) {
|
|
1975
|
+
return suspendForElicit(winner.value, entry.executePromise, entry.upstreamId, entry.calledName, entry.rpcId);
|
|
1976
|
+
}
|
|
1977
|
+
logErr(`bridge-b: retry did not declare the elicitation capability - auto-declined the follow-up for "${entry.calledName}"`);
|
|
1978
|
+
try { winner.value.client.respondToServer(winner.value.elicitId, { action: 'decline' }); } catch (_e) { /* ignore */ }
|
|
1979
|
+
try {
|
|
1980
|
+
const settled = await entry.executePromise;
|
|
1981
|
+
if (settled && typeof settled === 'object' && Array.isArray(settled.content)) return settled;
|
|
1982
|
+
} catch (_e) { /* falls through to the failure text */ }
|
|
1983
|
+
return { content: [{ type: 'text', text: `tool "${entry.calledName}" failed` }], isError: true };
|
|
1700
1984
|
}
|
|
1701
1985
|
const out = winner.value;
|
|
1702
1986
|
if (out && typeof out === 'object' && Array.isArray(out.content)) return out; // verbatim envelope
|
|
@@ -1706,6 +1990,28 @@ async function resumeElicit(params, modernCaller, currentPtId, cancelKey) {
|
|
|
1706
1990
|
/** Suspend a wrapped call that hit an elicitation: mint the token, hold the still-running
|
|
1707
1991
|
* execute promise, extend the held request's timeout to the suspension window (a human answers
|
|
1708
1992
|
* at human speed), arm the TTL, and shape the modern InputRequiredResult. */
|
|
1993
|
+
/**
|
|
1994
|
+
* callerAcceptsElicit - can THIS caller receive the given elicitation?
|
|
1995
|
+
* The caller's declared clientCapabilities is threaded EXPLICITLY to every decision site - no
|
|
1996
|
+
* silent default: an absent declaration is a caller that cannot receive backwards requests.
|
|
1997
|
+
* FORM mode (mode:'form' or neither mode nor url): the bare presence of `elicitation` suffices
|
|
1998
|
+
* (the schema's "form mode only (implicit)" example is `elicitation: {}`). URL mode
|
|
1999
|
+
* (mode:'url', or a url with no mode - the url IS the mode signal): requires the explicit
|
|
2000
|
+
* `elicitation.url` declaration. NEVER throws.
|
|
2001
|
+
* @param {object|null} clientCapabilities the caller's per-request declared capabilities
|
|
2002
|
+
* @param {object} elicitParams the upstream's elicitation/create params
|
|
2003
|
+
* @returns {boolean}
|
|
2004
|
+
*/
|
|
2005
|
+
function callerAcceptsElicit(clientCapabilities, elicitParams) {
|
|
2006
|
+
const caps = clientCapabilities && typeof clientCapabilities === 'object' ? clientCapabilities : null;
|
|
2007
|
+
const el = caps && caps.elicitation;
|
|
2008
|
+
if (!el || typeof el !== 'object' || Array.isArray(el)) return false;
|
|
2009
|
+
const p = elicitParams && typeof elicitParams === 'object' ? elicitParams : {};
|
|
2010
|
+
const isUrlMode = p.mode === 'url' || (p.mode === undefined && typeof p.url === 'string');
|
|
2011
|
+
if (isUrlMode) return !!(el.url && typeof el.url === 'object');
|
|
2012
|
+
return true;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1709
2015
|
function suspendForElicit(elicit, executePromise, upstreamId, calledName, rpcId) {
|
|
1710
2016
|
const token = require('node:crypto').randomBytes(16).toString('base64url');
|
|
1711
2017
|
executePromise.catch(() => { /* held in the background - its rejection surfaces on resume */ });
|
|
@@ -1733,10 +2039,16 @@ function suspendForElicit(elicit, executePromise, upstreamId, calledName, rpcId)
|
|
|
1733
2039
|
if (entry.timer && typeof entry.timer.unref === 'function') entry.timer.unref();
|
|
1734
2040
|
pendingElicits.set(token, entry);
|
|
1735
2041
|
// The MRTR result: the upstream's question under a minted key + the resume token. One
|
|
1736
|
-
// translation applies
|
|
1737
|
-
//
|
|
1738
|
-
// mode
|
|
1739
|
-
|
|
2042
|
+
// translation applies (5.3, and the OLD comment here was FALSE - form's `mode` is OPTIONAL,
|
|
2043
|
+
// schema: `mode?: "form"`): a bare pre-MRTR question (no mode, no url) keeps the harmless
|
|
2044
|
+
// explicit `mode:"form"` (pinned by wrap-wire W7); a `url`-carrying question with no mode is
|
|
2045
|
+
// URL mode by shape - complete the REQUIRED `mode:"url"` instead of stamping `form` onto a
|
|
2046
|
+
// contradiction; an explicit upstream mode always passes verbatim.
|
|
2047
|
+
const params = (elicit.params && elicit.params.mode !== undefined)
|
|
2048
|
+
? Object.assign({}, elicit.params)
|
|
2049
|
+
: (elicit.params && typeof elicit.params.url === 'string')
|
|
2050
|
+
? Object.assign({ mode: 'url' }, elicit.params)
|
|
2051
|
+
: Object.assign({ mode: 'form' }, elicit.params);
|
|
1740
2052
|
return {
|
|
1741
2053
|
resultType: 'input_required',
|
|
1742
2054
|
inputRequests: { r0: { method: 'elicitation/create', params } },
|
|
@@ -1748,7 +2060,7 @@ function suspendForElicit(elicit, executePromise, upstreamId, calledName, rpcId)
|
|
|
1748
2060
|
* armWrapChatter - sync the aggregator's wrapChatterUpstream from the on-disk wrap state.
|
|
1749
2061
|
* handleMessage re-arms per message, but three paths bypass it: startup (notifications before the
|
|
1750
2062
|
* first client message), a reloadExpose swap, and a listen-first modern client (the listen branch
|
|
1751
|
-
* is transport-owned). Each of those calls this so the
|
|
2063
|
+
* is transport-owned). Each of those calls this so the cross-upstream wrap-chatter filter is never unarmed.
|
|
1752
2064
|
* NEVER throws.
|
|
1753
2065
|
*/
|
|
1754
2066
|
function armWrapChatter(build) {
|
|
@@ -1783,10 +2095,10 @@ function armWrapChatter(build) {
|
|
|
1783
2095
|
* notifications/cancelled must be TRANSLATED before relay - relaying its requestId verbatim
|
|
1784
2096
|
* would cancel the wrong upstream call. Entries live only for the duration
|
|
1785
2097
|
* of the forward (deleted in the finally). Gated tools/call is NOT tracked here - full
|
|
1786
|
-
* id-translation for the tool path arrives with
|
|
2098
|
+
* id-translation for the tool path arrives with the elicitation bridge's correlation machinery. */
|
|
1787
2099
|
const inflightForwards = new Map();
|
|
1788
2100
|
|
|
1789
|
-
/** In-flight RAW (non-tool) forwards per upstream id - part of
|
|
2101
|
+
/** In-flight RAW (non-tool) forwards per upstream id - part of the elicitation bridge's binding invariant: a
|
|
1790
2102
|
* resources/read or prompts/get holds an open upstream call that may legally elicit, so while
|
|
1791
2103
|
* one is in flight an elicit's origin is ambiguous and must be declined. */
|
|
1792
2104
|
const inflightRawForwards = new Map();
|
|
@@ -1834,10 +2146,13 @@ async function forwardWrapped(aggregator, ptId, msg, hasId, id, wrapId, connKey)
|
|
|
1834
2146
|
// Strip ONLY the modern protocol keys from _meta. The rest - progressToken, trace keys, app
|
|
1835
2147
|
// keys - is legitimate in BOTH eras and a direct connection would deliver it verbatim.
|
|
1836
2148
|
// (McpClient.request() re-injects the trio, merged, when the upstream itself is modern.)
|
|
2149
|
+
// logLevel is EXEMPT (1.4): the McpClient era-keys it (modern: forwarded; legacy: translated
|
|
2150
|
+
// to logging/setLevel, never leaked) - stripping it here starved wrapped upstreams of the
|
|
2151
|
+
// one field that authorises notifications/message.
|
|
1837
2152
|
const meta = Object.assign({}, params._meta);
|
|
1838
2153
|
let changed = false;
|
|
1839
2154
|
for (const k of Object.keys(meta)) {
|
|
1840
|
-
if (k.startsWith('io.modelcontextprotocol/')) { delete meta[k]; changed = true; }
|
|
2155
|
+
if (k.startsWith('io.modelcontextprotocol/') && k !== modern.META_KEYS.LOG_LEVEL) { delete meta[k]; changed = true; }
|
|
1841
2156
|
}
|
|
1842
2157
|
if (changed) {
|
|
1843
2158
|
params = Object.assign({}, params);
|
|
@@ -1850,7 +2165,7 @@ async function forwardWrapped(aggregator, ptId, msg, hasId, id, wrapId, connKey)
|
|
|
1850
2165
|
// (connection, client id) - raw client ids collide across connections (focused review).
|
|
1851
2166
|
const mapKey = connKey !== undefined ? `${connKey}:${id}` : null;
|
|
1852
2167
|
const track = mapKey === null ? undefined : { set rpcId(v) { inflightForwards.set(mapKey, v); } };
|
|
1853
|
-
inflightRawForwards.set(ptId, (inflightRawForwards.get(ptId) || 0) + 1); //
|
|
2168
|
+
inflightRawForwards.set(ptId, (inflightRawForwards.get(ptId) || 0) + 1); // elicitation-bridge binding invariant
|
|
1854
2169
|
try {
|
|
1855
2170
|
raw = await aggregator.forwardRaw(ptId, msg.method, params, track);
|
|
1856
2171
|
} catch (err) {
|
|
@@ -1912,6 +2227,12 @@ async function handleModernMessage(build, msg, hasId, id, method, wrapId, connKe
|
|
|
1912
2227
|
case 'tools/list':
|
|
1913
2228
|
// A notification (no id) MUST NOT get a reply (JSON-RPC 2.0). Every case guards on hasId.
|
|
1914
2229
|
if (!hasId) return null;
|
|
2230
|
+
// 6.1: the funnel's own list is SINGLE-PAGE, so any present cursor is unrecognised ->
|
|
2231
|
+
// -32602 (the modern era is cursor-aware; the transport's 4.1 map gives it HTTP 400).
|
|
2232
|
+
if (msg.params && msg.params.cursor !== undefined) {
|
|
2233
|
+
return makeError(id, ERR.INVALID_PARAMS,
|
|
2234
|
+
`Invalid params: unknown cursor "${String(msg.params.cursor)}" (this tools/list is single-page)`);
|
|
2235
|
+
}
|
|
1915
2236
|
return makeResult(
|
|
1916
2237
|
id,
|
|
1917
2238
|
modern.decorateResult(
|
|
@@ -1925,14 +2246,20 @@ async function handleModernMessage(build, msg, hasId, id, method, wrapId, connKe
|
|
|
1925
2246
|
const result = await handleToolsCall(
|
|
1926
2247
|
protocol, aggregator,
|
|
1927
2248
|
// cancelKey: same cancel-translation contract as the legacy path.
|
|
1928
|
-
// modernCaller: a modern client can receive input_required + retry (
|
|
2249
|
+
// modernCaller: a modern client can receive input_required + retry (the elicitation bridge / MRTR).
|
|
2250
|
+
// clientCapabilities (5.1, EXPLICIT - no silent default): whether THIS request may
|
|
2251
|
+
// receive an elicitation is decided by what THIS request declared.
|
|
1929
2252
|
{ engine, ctx, registry, toolStatePath, modernCaller: true,
|
|
2253
|
+
clientCapabilities: (modern.getMeta(msg) || {})[modern.META_KEYS.CLIENT_CAPABILITIES] || null,
|
|
1930
2254
|
cancelKey: (connKey !== undefined && hasId) ? `${connKey}:${id}` : null },
|
|
1931
2255
|
msg.params
|
|
1932
2256
|
);
|
|
1933
2257
|
// Same observability chokepoint as the legacy path - both eras count in one place.
|
|
1934
|
-
metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && result.isError === true) });
|
|
2258
|
+
metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && (result.isError === true || result.__tfRpcError)) });
|
|
1935
2259
|
if (!hasId) return null; // notification-shaped call: executed (side effects), but never replied to
|
|
2260
|
+
// Relayed upstream error: mint the era-shaped JSON-RPC error BEFORE decoration -
|
|
2261
|
+
// an error is never dressed as a result.
|
|
2262
|
+
if (result && result.__tfRpcError) return relayedRpcError(id, result, true);
|
|
1936
2263
|
const decorated = modern.decorateResult(result, resultServerInfo);
|
|
1937
2264
|
// Legacy-shim transparency for MODERN clients: when the call ACTUALLY forwarded to a PINNED
|
|
1938
2265
|
// legacy upstream, say so in result _meta - metadata, never content. NOT under a wrap: a wrap
|
|
@@ -1966,8 +2293,8 @@ async function handleModernMessage(build, msg, hasId, id, method, wrapId, connKe
|
|
|
1966
2293
|
* emitToolsListChanged(send) - write the MCP `notifications/tools/list_changed` JSON-RPC
|
|
1967
2294
|
* notification (NO id - it is a notification, not a request) via the provided `send` fn. The
|
|
1968
2295
|
* server emits this when the curated-direct expose set hot-updates so a CLI that honours
|
|
1969
|
-
* listChanged re-fetches tools/list (
|
|
1970
|
-
* changes";
|
|
2296
|
+
* listChanged re-fetches tools/list ("no restart for register
|
|
2297
|
+
* changes"; the host owns the server). `send` is injected so this is unit-testable with a spy
|
|
1971
2298
|
* and reused by both the stdio loop and (later) the host.
|
|
1972
2299
|
*
|
|
1973
2300
|
* @param {(obj: object) => void} send the transport's writer (stdio loop's send, or a spy)
|
|
@@ -2013,7 +2340,7 @@ async function reloadExpose(build, send) {
|
|
|
2013
2340
|
return;
|
|
2014
2341
|
}
|
|
2015
2342
|
const next = new Aggregator({
|
|
2016
|
-
store,
|
|
2343
|
+
store, gatewayRoot: ROOT,
|
|
2017
2344
|
wrapTargetProvider: () => {
|
|
2018
2345
|
try { return getPassthrough(loadToolStateResult(TOOL_STATE_PATH).state); } catch (_e) { return null; }
|
|
2019
2346
|
},
|
|
@@ -2043,7 +2370,7 @@ async function reloadExpose(build, send) {
|
|
|
2043
2370
|
if (prevAgg && typeof prevAgg.onUpstreamNotification === 'function') {
|
|
2044
2371
|
next.onUpstreamNotification = prevAgg.onUpstreamNotification;
|
|
2045
2372
|
}
|
|
2046
|
-
//
|
|
2373
|
+
// Elicitation-bridge carryover - same contract as the notification bridge above: the handler closes over
|
|
2047
2374
|
// the stable build object, so it transfers verbatim onto the swapped-in aggregator.
|
|
2048
2375
|
if (prevAgg && typeof prevAgg.onUpstreamServerRequest === 'function') {
|
|
2049
2376
|
next.onUpstreamServerRequest = prevAgg.onUpstreamServerRequest;
|
|
@@ -2058,6 +2385,7 @@ async function reloadExpose(build, send) {
|
|
|
2058
2385
|
// to silently kill every agreed subscription channel).
|
|
2059
2386
|
if (prevAgg && prevAgg._subscribedUris instanceof Set && prevAgg._subscribedUris.size) {
|
|
2060
2387
|
next._subscribedUris = new Set(prevAgg._subscribedUris);
|
|
2388
|
+
if (prevAgg._subscribedRefs instanceof Map) next._subscribedRefs = new Map(prevAgg._subscribedRefs); // 5.4
|
|
2061
2389
|
}
|
|
2062
2390
|
let res = null;
|
|
2063
2391
|
try {
|
|
@@ -2074,7 +2402,7 @@ async function reloadExpose(build, send) {
|
|
|
2074
2402
|
try {
|
|
2075
2403
|
const prev = build.aggregator;
|
|
2076
2404
|
build.aggregator = next; // swap BEFORE closing the old one (handleMessage reads build fresh)
|
|
2077
|
-
armWrapChatter(build); // the fresh aggregator's
|
|
2405
|
+
armWrapChatter(build); // the fresh aggregator's wrap-chatter filter must not wait for a client message
|
|
2078
2406
|
if (prev && typeof prev.closeAll === 'function') {
|
|
2079
2407
|
try { await prev.closeAll(); } catch (_e) { /* closeAll never throws, but guard teardown */ }
|
|
2080
2408
|
}
|
|
@@ -2322,12 +2650,20 @@ function startConfigWatchers(buildArg, send) {
|
|
|
2322
2650
|
// stdin framing reader - accepts BOTH Content-Length framing and newline-delimited JSON.
|
|
2323
2651
|
// We write newline-delimited JSON on stdout.
|
|
2324
2652
|
//
|
|
2325
|
-
// `build` is the
|
|
2653
|
+
// `build` is the full build object { protocol, aggregator, engine, ctx } (a bare protocol is
|
|
2326
2654
|
// accepted too - see normaliseBuild). It is threaded straight into handleMessage so tools/list
|
|
2327
2655
|
// advertises the curated-direct surface and tools/call gates curated-direct calls.
|
|
2328
2656
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
2657
|
+
// Hard ceiling on one buffered stdin message (matches http-transport's MAX_BODY_BYTES and the
|
|
2658
|
+
// client-side cap in mcp-client.js). A declared Content-Length over the cap is refused with the
|
|
2659
|
+
// same clean -32700 the HTTP transport gives an over-cap body, and its bytes are discarded as
|
|
2660
|
+
// they arrive so the pipe stays in frame; a buffer past the cap with NO complete frame has no
|
|
2661
|
+
// resync point, so the session ends instead of buffering toward OOM.
|
|
2662
|
+
const MAX_MESSAGE_BYTES = 4 * 1024 * 1024;
|
|
2663
|
+
|
|
2329
2664
|
function createStdioLoop(build) {
|
|
2330
2665
|
let buf = Buffer.alloc(0);
|
|
2666
|
+
let skipRemaining = 0; // bytes of a refused over-cap message still to discard
|
|
2331
2667
|
// Serialise message handling so responses are written in request order even though handlers
|
|
2332
2668
|
// are async (avoids interleaving partial writes on stdout).
|
|
2333
2669
|
let chain = Promise.resolve();
|
|
@@ -2402,6 +2738,18 @@ function createStdioLoop(build) {
|
|
|
2402
2738
|
// never saw the request. `started` closes the mid-processing race: once the link runs,
|
|
2403
2739
|
// the cancel falls through to handleMessage, whose in-flight map translates + relays.
|
|
2404
2740
|
if (msg.params && msg.params.requestId !== undefined) {
|
|
2741
|
+
// 5.4: HONOUR a cancel naming a LISTEN id - terminate that subscription: release its
|
|
2742
|
+
// listen-owned resource URIs and drop the registration. Per the cancellation rules a
|
|
2743
|
+
// cancelled request gets NO further messages, so no closing result is sent.
|
|
2744
|
+
const li = modernSubs.findIndex((sub) => sub.id === msg.params.requestId);
|
|
2745
|
+
if (li !== -1) {
|
|
2746
|
+
const sub = modernSubs.splice(li, 1)[0];
|
|
2747
|
+
if (sub && sub.agreed && Array.isArray(sub.agreed.resourceSubscriptions)) {
|
|
2748
|
+
const agg = build && build.aggregator;
|
|
2749
|
+
if (agg && typeof agg.releaseResources === 'function') agg.releaseResources(sub.agreed.resourceSubscriptions);
|
|
2750
|
+
}
|
|
2751
|
+
return; // the cancel is consumed - it named a listen, not an in-flight call
|
|
2752
|
+
}
|
|
2405
2753
|
const queued = queuedRequests.get(msg.params.requestId);
|
|
2406
2754
|
if (queued) {
|
|
2407
2755
|
// Started or not: a cancelled request receives NO response (spec). For a STARTED
|
|
@@ -2430,6 +2778,15 @@ function createStdioLoop(build) {
|
|
|
2430
2778
|
if (reg.cancelled) { queuedRequests.delete(msg.id); return; } // pre-cancelled: no reply (spec)
|
|
2431
2779
|
}
|
|
2432
2780
|
try {
|
|
2781
|
+
// JSON-RPC BATCH: admitted ONLY on a session that negotiated 2025-03-26 -
|
|
2782
|
+
// the one revision that defines batching. Any other session lets the array fall through
|
|
2783
|
+
// to handleMessage's frozen -32600 rejection below. Ordered on the same chain as every
|
|
2784
|
+
// single message, so batch members can never overtake their neighbours.
|
|
2785
|
+
if (Array.isArray(msg) && build && build.negotiatedLegacyVersion === '2025-03-26') {
|
|
2786
|
+
const batched = await handleBatch(build, msg, 'stdio');
|
|
2787
|
+
if (batched != null) send(batched);
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2433
2790
|
// subscriptions/listen (modern) is TRANSPORT-owned: the ack and every later notification
|
|
2434
2791
|
// ride this same stdout pipe, and the final result is written when stdin closes (see the
|
|
2435
2792
|
// 'end' handler). Everything else routes through handleMessage unchanged.
|
|
@@ -2457,8 +2814,18 @@ function createStdioLoop(build) {
|
|
|
2457
2814
|
// ack) must not accumulate a duplicate registration -> doubled notifications forever
|
|
2458
2815
|
//Overwrite the existing entry; otherwise append.
|
|
2459
2816
|
const existing = modernSubs.findIndex((s) => s.id === msg.id);
|
|
2460
|
-
if (existing !== -1)
|
|
2461
|
-
|
|
2817
|
+
if (existing !== -1) {
|
|
2818
|
+
// 5.4: the REPLACED registration's resource subscriptions were a leak site -
|
|
2819
|
+
// release them before the overwrite (the new agreed set took its own refs above).
|
|
2820
|
+
const old = modernSubs[existing];
|
|
2821
|
+
if (old && old.agreed && Array.isArray(old.agreed.resourceSubscriptions)) {
|
|
2822
|
+
const agg = build && build.aggregator;
|
|
2823
|
+
if (agg && typeof agg.releaseResources === 'function') agg.releaseResources(old.agreed.resourceSubscriptions);
|
|
2824
|
+
}
|
|
2825
|
+
modernSubs[existing] = { id: msg.id, agreed };
|
|
2826
|
+
} else {
|
|
2827
|
+
modernSubs.push({ id: msg.id, agreed });
|
|
2828
|
+
}
|
|
2462
2829
|
send(modern.listenAck(msg.id, agreed));
|
|
2463
2830
|
return;
|
|
2464
2831
|
}
|
|
@@ -2475,17 +2842,37 @@ function createStdioLoop(build) {
|
|
|
2475
2842
|
});
|
|
2476
2843
|
}
|
|
2477
2844
|
|
|
2845
|
+
// True when `buf` BEGINS with a framing header whose block terminator hasn't arrived yet.
|
|
2846
|
+
function headerInFlight() {
|
|
2847
|
+
return /^content-(length|type):/i.test(buf.slice(0, 16).toString('utf8'));
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2478
2850
|
// Try to pull one Content-Length-framed message off the front of `buf`. Returns true if it
|
|
2479
|
-
// consumed one (and enqueued it), false if
|
|
2851
|
+
// consumed one (and enqueued it), false if this isn't header framing, or 'wait' if a header
|
|
2852
|
+
// frame is only PARTLY here - the drain must hold rather than let line framing eat the header
|
|
2853
|
+
// (the eaten header orphaned the body: the frame was lost and the client saw only a timeout).
|
|
2480
2854
|
function tryHeaderFramed() {
|
|
2481
2855
|
const headerEnd = buf.indexOf('\r\n\r\n');
|
|
2482
|
-
if (headerEnd === -1)
|
|
2856
|
+
if (headerEnd === -1) {
|
|
2857
|
+
// No complete header block - but a buffer that STARTS with a header line is a header
|
|
2858
|
+
// frame whose terminator is still in flight, not line-framed junk.
|
|
2859
|
+
return headerInFlight() ? 'wait' : false;
|
|
2860
|
+
}
|
|
2483
2861
|
const header = buf.slice(0, headerEnd).toString('utf8');
|
|
2484
2862
|
const m = /Content-Length:\s*(\d+)/i.exec(header);
|
|
2485
2863
|
if (!m) return false; // header block without a Content-Length -> not this framing
|
|
2486
2864
|
const len = parseInt(m[1], 10);
|
|
2487
2865
|
const bodyStart = headerEnd + 4;
|
|
2488
|
-
if (
|
|
2866
|
+
if (len > MAX_MESSAGE_BYTES) {
|
|
2867
|
+
// Refuse the message, keep the session: answer the same clean -32700 the HTTP transport
|
|
2868
|
+
// gives an over-cap body, consume the header, and let drain() discard exactly the
|
|
2869
|
+
// declared bytes as they arrive so the pipe stays in frame.
|
|
2870
|
+
send(makeError(null, ERR.PARSE, 'Parse error: message too large'));
|
|
2871
|
+
buf = buf.slice(bodyStart);
|
|
2872
|
+
skipRemaining = len;
|
|
2873
|
+
return true;
|
|
2874
|
+
}
|
|
2875
|
+
if (buf.length < bodyStart + len) return 'wait'; // body still in flight - hold the drain
|
|
2489
2876
|
const body = buf.slice(bodyStart, bodyStart + len).toString('utf8');
|
|
2490
2877
|
buf = buf.slice(bodyStart + len);
|
|
2491
2878
|
let obj = null;
|
|
@@ -2507,9 +2894,9 @@ function createStdioLoop(build) {
|
|
|
2507
2894
|
const line = buf.slice(0, nl).toString('utf8').trim();
|
|
2508
2895
|
buf = buf.slice(nl + 1);
|
|
2509
2896
|
if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
|
|
2510
|
-
// A
|
|
2511
|
-
//
|
|
2512
|
-
//
|
|
2897
|
+
// A header line at the FRONT of the buffer never reaches this path (header framing holds the
|
|
2898
|
+
// drain with 'wait'); any non-JSON line here is a framing artifact - ignore it rather than
|
|
2899
|
+
// erroring.
|
|
2513
2900
|
if (line[0] !== '{' && line[0] !== '[') return true;
|
|
2514
2901
|
let obj = null;
|
|
2515
2902
|
try {
|
|
@@ -2524,9 +2911,20 @@ function createStdioLoop(build) {
|
|
|
2524
2911
|
|
|
2525
2912
|
function drain() {
|
|
2526
2913
|
// Header framing first (it counts exact bytes and is unambiguous); fall back to line framing.
|
|
2527
|
-
// Loop until neither can make progress.
|
|
2914
|
+
// Loop until neither can make progress. 'wait' stops the drain outright: a header frame is
|
|
2915
|
+
// mid-flight and line framing must not be allowed to eat its header.
|
|
2528
2916
|
for (;;) {
|
|
2529
|
-
if (
|
|
2917
|
+
if (skipRemaining > 0) {
|
|
2918
|
+
// A refused over-cap message: discard exactly its declared bytes as they arrive so the
|
|
2919
|
+
// pipe stays in frame for everything after it.
|
|
2920
|
+
const eat = Math.min(skipRemaining, buf.length);
|
|
2921
|
+
buf = buf.slice(eat);
|
|
2922
|
+
skipRemaining -= eat;
|
|
2923
|
+
if (skipRemaining > 0) return; // the rest of the refused body is still in flight
|
|
2924
|
+
}
|
|
2925
|
+
const h = tryHeaderFramed();
|
|
2926
|
+
if (h === true) continue;
|
|
2927
|
+
if (h === 'wait') break; // an incomplete header frame - wait for more bytes
|
|
2530
2928
|
if (tryLineFramed()) continue;
|
|
2531
2929
|
break;
|
|
2532
2930
|
}
|
|
@@ -2535,12 +2933,35 @@ function createStdioLoop(build) {
|
|
|
2535
2933
|
process.stdin.on('data', (chunk) => {
|
|
2536
2934
|
buf = Buffer.concat([buf, chunk]);
|
|
2537
2935
|
drain();
|
|
2936
|
+
if (buf.length > MAX_MESSAGE_BYTES) {
|
|
2937
|
+
// Past the cap with no complete frame there is no resync point. Flush a clean parse
|
|
2938
|
+
// error, then end the session (the stdio session IS the process) instead of buffering
|
|
2939
|
+
// toward OOM. The exit waits for the error to flush - a hard exit can drop unflushed
|
|
2940
|
+
// pipe bytes - with a timer backstop in case the pipe never drains.
|
|
2941
|
+
logErr(`stdin buffered ${buf.length} bytes with no complete message (cap ${MAX_MESSAGE_BYTES}); ending session`);
|
|
2942
|
+
buf = Buffer.alloc(0);
|
|
2943
|
+
let line;
|
|
2944
|
+
try {
|
|
2945
|
+
line = JSON.stringify(makeError(null, ERR.PARSE, 'Parse error: message too large'));
|
|
2946
|
+
} catch (_e) {
|
|
2947
|
+
line = '{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error: message too large"}}';
|
|
2948
|
+
}
|
|
2949
|
+
try { process.stdin.destroy(); } catch (_e) { /* ignore */ }
|
|
2950
|
+
setTimeout(() => process.exit(1), 500);
|
|
2951
|
+
process.stdout.write(line + '\n', () => process.exit(1));
|
|
2952
|
+
}
|
|
2538
2953
|
});
|
|
2539
2954
|
process.stdin.on('end', () => {
|
|
2540
2955
|
// Flush any complete trailing message, then gracefully close each modern listen subscription
|
|
2541
2956
|
// (the spec ends a subscription with a final RESULT to the listen id) before the process exits.
|
|
2542
2957
|
drain();
|
|
2543
2958
|
for (const sub of modernSubs.splice(0)) {
|
|
2959
|
+
// 5.4: release the subscription's listen-owned resource URIs (the wholesale close was a
|
|
2960
|
+
// leak site - the upstream kept emitting updates nobody could receive).
|
|
2961
|
+
if (sub && sub.agreed && Array.isArray(sub.agreed.resourceSubscriptions)) {
|
|
2962
|
+
const agg = build && build.aggregator;
|
|
2963
|
+
if (agg && typeof agg.releaseResources === 'function') agg.releaseResources(sub.agreed.resourceSubscriptions);
|
|
2964
|
+
}
|
|
2544
2965
|
try { send(modern.listenClose(sub.id)); } catch (_e) { /* pipe may already be gone */ }
|
|
2545
2966
|
}
|
|
2546
2967
|
});
|
|
@@ -2561,7 +2982,7 @@ function createStdioLoop(build) {
|
|
|
2561
2982
|
// Entry point.
|
|
2562
2983
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
2563
2984
|
async function main() {
|
|
2564
|
-
// The
|
|
2985
|
+
// The full build object { protocol, aggregator, engine, ctx }.
|
|
2565
2986
|
let build;
|
|
2566
2987
|
try {
|
|
2567
2988
|
build = buildProtocol();
|
|
@@ -2596,7 +3017,7 @@ async function main() {
|
|
|
2596
3017
|
logErr('aggregator.connectAll threw (ignored):', (err && err.message) || String(err));
|
|
2597
3018
|
}
|
|
2598
3019
|
}
|
|
2599
|
-
armWrapChatter(build); //
|
|
3020
|
+
armWrapChatter(build); // wrap-chatter filter armed from boot - not only from the first client message
|
|
2600
3021
|
|
|
2601
3022
|
// Keep the process alive on the stdio loop; never let an unhandled rejection kill it.
|
|
2602
3023
|
process.on('uncaughtException', (err) => logErr('uncaughtException:', (err && err.stack) || String(err)));
|
|
@@ -2622,7 +3043,7 @@ async function main() {
|
|
|
2622
3043
|
if (wrapOnly && uid !== wrapOnly) return;
|
|
2623
3044
|
loop.notify(n);
|
|
2624
3045
|
};
|
|
2625
|
-
//
|
|
3046
|
+
// Elicitation bridge: server-initiated upstream requests (elicitation/create) route to the bridge; it
|
|
2626
3047
|
// binds them to the one in-flight wrapped call or declines. `build` is the stable stdio object.
|
|
2627
3048
|
if (build.aggregator) build.aggregator.onUpstreamServerRequest =
|
|
2628
3049
|
(uid, m, c) => handleUpstreamServerRequest(build, uid, m, c);
|
|
@@ -2650,24 +3071,27 @@ module.exports = {
|
|
|
2650
3071
|
buildProtocol,
|
|
2651
3072
|
makeRegistryAdapter,
|
|
2652
3073
|
handleMessage,
|
|
3074
|
+
// 2.3: the batch processor - the TRANSPORT owns the admission gate (stdio: negotiated
|
|
3075
|
+
// 2025-03-26 session; HTTP: absent-or-2025-03-26 header).
|
|
3076
|
+
handleBatch,
|
|
2653
3077
|
handleInitialize,
|
|
2654
3078
|
// The stdio entry point - an external bin/ can require this module and call main() to start the
|
|
2655
3079
|
// server (build -> connect upstreams -> run the stdio loop). main() is async and takes no args.
|
|
2656
3080
|
main,
|
|
2657
|
-
//
|
|
3081
|
+
// Full-build + MATRIX handlers: handleToolsList(protocol, aggregator, { registry?, toolStatePath? })
|
|
2658
3082
|
// and handleToolsCall(protocol, aggregator, { engine, ctx, registry?, toolStatePath? }, params).
|
|
2659
3083
|
handleToolsList,
|
|
2660
3084
|
handleToolsCall,
|
|
2661
|
-
//
|
|
3085
|
+
// wrap-chatter filter arming - the HTTP transport calls this at start/reload and in its listen branch.
|
|
2662
3086
|
armWrapChatter,
|
|
2663
|
-
//
|
|
3087
|
+
// Elicitation bridge: the HTTP transport wires this as the aggregator's onUpstreamServerRequest handler.
|
|
2664
3088
|
handleUpstreamServerRequest,
|
|
2665
3089
|
// Matrix helpers (exported for unit tests / reuse).
|
|
2666
3090
|
localHotDefinitions,
|
|
2667
3091
|
localToolDefinition,
|
|
2668
3092
|
isPromotedLocal,
|
|
2669
3093
|
isPromotedUpstream,
|
|
2670
|
-
//
|
|
3094
|
+
// Emit the hot-update notification (no-id notifications/tools/list_changed) via send.
|
|
2671
3095
|
emitToolsListChanged,
|
|
2672
3096
|
// Hot-reload: re-read on-disk config live (no restart). Wired to fs.watch in main().
|
|
2673
3097
|
reloadExpose,
|