toolfunnel 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +235 -167
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -147
  4. package/docs/KNOWN_BUGS.md +80 -33
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -0
  7. package/docs/packaging.md +115 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -31
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -0
  13. package/mcp/servers/mock-upstream/server.js +222 -12
  14. package/package.json +67 -65
  15. package/src/auth/config.js +174 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +353 -344
  18. package/src/core/child-reaper.js +61 -0
  19. package/src/core/config-home.js +134 -134
  20. package/src/core/events.js +105 -105
  21. package/src/core/hook-engine.js +329 -275
  22. package/src/core/hook-loader.js +824 -791
  23. package/src/core/hook-runner.js +566 -346
  24. package/src/core/logger.js +297 -171
  25. package/src/core/matcher.js +100 -51
  26. package/src/core/metrics.js +75 -75
  27. package/src/core/rename-migration.js +163 -0
  28. package/src/core/requires.js +138 -138
  29. package/src/core/server-config.js +98 -77
  30. package/src/extend/add-hook.md +152 -142
  31. package/src/extend/add-mcp.md +119 -121
  32. package/src/extend/configure.md +88 -0
  33. package/src/extend/create-tool.md +168 -152
  34. package/src/extend/howto.js +102 -98
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -0
  37. package/src/mcp/aggregator.js +1674 -807
  38. package/src/mcp/expose-store.js +577 -430
  39. package/src/mcp/gated-run.js +210 -194
  40. package/src/mcp/http-transport.js +1563 -1012
  41. package/src/mcp/mcp-client.js +1070 -530
  42. package/src/mcp/modern.js +406 -0
  43. package/src/mcp/protocol.js +468 -466
  44. package/src/mcp/server.js +3112 -1405
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1187 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +120 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +14 -3
  70. package/src/core/tool-registry.js +0 -180
  71. package/src/packages/packager.js +0 -715
  72. package/src/tools/drift.js +0 -138
@@ -0,0 +1,406 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * modern.js - the 2026-07-28 ("modern") protocol era, as a pure module.
5
+ *
6
+ * The 2026-07-28 MCP revision is a BREAKING change: it removes the `initialize` handshake,
7
+ * protocol-level sessions and the standalone GET/SSE stream, and replaces them with per-request
8
+ * `_meta` protocol fields, required HTTP routing headers, `server/discover` and
9
+ * `subscriptions/listen`. ToolFunnel 0.6.0 is a DUAL-ERA server: a request carrying modern
10
+ * per-request `_meta` is served under these semantics; an `initialize` request selects the
11
+ * legacy (2024-11-05) path, which is byte-for-byte the 0.5.0 behaviour. The spec's dual-era
12
+ * server rule ("MAY serve both eras concurrently on the same endpoint or process") is exactly
13
+ * what this module enables.
14
+ *
15
+ * This module is PURE and transport-free (the protocol.js philosophy): it owns every modern-era
16
+ * SHAPE - era detection, request validation, header validation, result decoration,
17
+ * `server/discover`, the `subscriptions/listen` message shapes and the modern error codes - and
18
+ * executes nothing. `server.js` (stdio) and `http-transport.js` bind it to their wires.
19
+ *
20
+ * Spec sources (RC, locked 2026-05-21; re-verify against the FINAL spec before release):
21
+ * - modelcontextprotocol.io/specification/draft/changelog
22
+ * - .../draft/basic/transports/streamable-http (headers, validation, error examples)
23
+ * - .../draft/basic/versioning (era model, compatibility matrix)
24
+ * - .../draft/server/discover (DiscoverResult shape)
25
+ * - .../draft/basic/patterns/subscriptions (subscriptions/listen shapes)
26
+ * - .../draft/basic/index (_meta table, resultType, error allocation)
27
+ *
28
+ * CommonJS only. Node built-ins only. No transport. No SDK.
29
+ */
30
+
31
+ /** The modern protocol revision this server implements. */
32
+ const MODERN_PROTOCOL_VERSION = '2026-07-28';
33
+
34
+ /** Reserved `_meta` key prefix + the specific keys the spec defines. */
35
+ const META_KEYS = Object.freeze({
36
+ PROTOCOL_VERSION: 'io.modelcontextprotocol/protocolVersion',
37
+ CLIENT_CAPABILITIES: 'io.modelcontextprotocol/clientCapabilities',
38
+ CLIENT_INFO: 'io.modelcontextprotocol/clientInfo',
39
+ SERVER_INFO: 'io.modelcontextprotocol/serverInfo',
40
+ SUBSCRIPTION_ID: 'io.modelcontextprotocol/subscriptionId',
41
+ LOG_LEVEL: 'io.modelcontextprotocol/logLevel',
42
+ });
43
+
44
+ /** Modern-era JSON-RPC error codes (-32020..-32099 is the spec-reserved range). */
45
+ const ERR_MODERN = Object.freeze({
46
+ HEADER_MISMATCH: -32020,
47
+ MISSING_REQUIRED_CLIENT_CAPABILITY: -32021,
48
+ UNSUPPORTED_PROTOCOL_VERSION: -32022,
49
+ });
50
+
51
+ /** JSON-RPC codes shared with the legacy path (kept local so this module stays dependency-free). */
52
+ const ERR_INVALID_PARAMS = -32602;
53
+ const ERR_METHOD_NOT_FOUND = -32601;
54
+
55
+ /**
56
+ * Cache hints for the modern CacheableResult surfaces. tools/list can change on any config edit
57
+ * (and we push list_changed when it does), so the freshness hint is short; discover output only
58
+ * changes on upgrade/identity edits, so it can live longer. Both are 'private': this is a
59
+ * per-user gateway whose surface is the USER's visibility matrix - a shared cache must not serve
60
+ * one user's tool surface to another.
61
+ */
62
+ const CACHE_HINTS = Object.freeze({
63
+ toolsList: Object.freeze({ ttlMs: 60000, cacheScope: 'private' }),
64
+ discover: Object.freeze({ ttlMs: 3600000, cacheScope: 'private' }),
65
+ });
66
+
67
+ /** The methods whose Mcp-Name header must mirror params.name / params.uri. */
68
+ const NAMED_METHODS = Object.freeze({
69
+ 'tools/call': 'name',
70
+ 'resources/read': 'uri',
71
+ 'prompts/get': 'name',
72
+ });
73
+
74
+ /** Safely pull params._meta from a parsed JSON-RPC message. Returns null when absent/malformed. */
75
+ function getMeta(msg) {
76
+ const meta = msg && msg.params && msg.params._meta;
77
+ return meta && typeof meta === 'object' && !Array.isArray(meta) ? meta : null;
78
+ }
79
+
80
+ /**
81
+ * Era detection (the spec's dual-era server rule): a request is MODERN iff its params._meta
82
+ * carries the protocolVersion key. `initialize` is ALWAYS legacy - the spec says an initialize
83
+ * request selects legacy semantics, so callers must check the method first (server.js does).
84
+ * @param {object} msg a parsed JSON-RPC message
85
+ * @returns {boolean}
86
+ */
87
+ function isModernRequest(msg) {
88
+ const meta = getMeta(msg);
89
+ return !!(meta && typeof meta[META_KEYS.PROTOCOL_VERSION] === 'string');
90
+ }
91
+
92
+ /**
93
+ * Validate a modern request's `_meta` protocol fields. Returns null when valid, else a
94
+ * descriptor { code, message, data?, httpStatus } the caller turns into a JSON-RPC error
95
+ * (HTTP transports also use httpStatus - the spec mandates 400 for all of these).
96
+ *
97
+ * Spec: protocolVersion + clientCapabilities are MUST; a missing required field is malformed
98
+ * (-32602 + HTTP 400); an unsupported version is -32022 with data.supported/requested.
99
+ * @param {object} msg a parsed JSON-RPC message already known to be modern-shaped
100
+ * @returns {null | { code:number, message:string, data?:object, httpStatus:number }}
101
+ */
102
+ function validateModernRequest(msg) {
103
+ const meta = getMeta(msg);
104
+ if (!meta) {
105
+ return {
106
+ code: ERR_INVALID_PARAMS,
107
+ message: 'Invalid params: modern requests require params._meta',
108
+ httpStatus: 400,
109
+ };
110
+ }
111
+ const version = meta[META_KEYS.PROTOCOL_VERSION];
112
+ if (version !== MODERN_PROTOCOL_VERSION) {
113
+ return {
114
+ code: ERR_MODERN.UNSUPPORTED_PROTOCOL_VERSION,
115
+ message: 'Unsupported protocol version',
116
+ // Filtered AT THE ERROR SITE only: this `supported` answers "what may _meta
117
+ // protocolVersion carry" and the only lawful value is the modern version. Advertising the
118
+ // legacy versions here re-armed a measured retry loop - a client offered 2024-11-05
119
+ // re-pins it in _meta and is rejected forever (they are initialize-channel vocabulary,
120
+ // not _meta vocabulary). supportedVersions() itself stays whole: server/discover and
121
+ // /health legitimately span both eras.
122
+ data: { supported: [MODERN_PROTOCOL_VERSION], requested: version },
123
+ httpStatus: 400,
124
+ };
125
+ }
126
+ const caps = meta[META_KEYS.CLIENT_CAPABILITIES];
127
+ if (!caps || typeof caps !== 'object' || Array.isArray(caps)) {
128
+ return {
129
+ code: ERR_INVALID_PARAMS,
130
+ message:
131
+ 'Invalid params: _meta["' + META_KEYS.CLIENT_CAPABILITIES + '"] (object) is required',
132
+ httpStatus: 400,
133
+ };
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * Validate the modern HTTP routing headers against the request body (HTTP transport only -
140
+ * stdio has no headers). Called ONLY for requests already detected as modern. Enforcement is
141
+ * strict per the spec ("These headers are REQUIRED for compliance"):
142
+ * - MCP-Protocol-Version MUST be present and MUST match the body's _meta protocolVersion.
143
+ * - Mcp-Method MUST be present on all requests and MUST match the body method.
144
+ * - Mcp-Name MUST be present on tools/call | resources/read | prompts/get and MUST match
145
+ * params.name / params.uri.
146
+ * Returns null when valid, else { code, message, httpStatus:400 }.
147
+ * @param {object} headers node req.headers (lower-cased keys)
148
+ * @param {object} msg the parsed JSON-RPC body
149
+ * @returns {null | { code:number, message:string, httpStatus:number }}
150
+ */
151
+ /**
152
+ * Decode a header value that carries the Base64 sentinel `=?base64?{data}?=` (the
153
+ * 2026-07-28 Streamable HTTP spec, Base64-Encoded Header Values). UNCONDITIONAL on the
154
+ * sentinel shape - the spec obliges clients
155
+ * to encode ANY value that even looks like the sentinel, so there is no legal raw value of this
156
+ * shape and no try-decode-and-fall-back: a sentinel-shaped header decodes (possibly to a
157
+ * mismatching string - node's forgiving base64 makes garbage compare unequal, which fails
158
+ * closed at the comparison site). Markers are case-sensitive lowercase per spec. Non-sentinel
159
+ * values pass through verbatim.
160
+ * @param {any} v the raw header value
161
+ * @returns {any} the decoded string, or v untouched when not sentinel-shaped
162
+ */
163
+ function decodeHeaderSentinel(v) {
164
+ if (typeof v !== 'string') return v;
165
+ if (v.length >= 11 && v.startsWith('=?base64?') && v.endsWith('?=')) {
166
+ return Buffer.from(v.slice(9, -2), 'base64').toString('utf8');
167
+ }
168
+ return v;
169
+ }
170
+
171
+ function validateModernHeaders(headers, msg) {
172
+ const h = headers || {};
173
+ const mismatch = (message) => ({ code: ERR_MODERN.HEADER_MISMATCH, message, httpStatus: 400 });
174
+
175
+ const meta = getMeta(msg) || {};
176
+ const bodyVersion = meta[META_KEYS.PROTOCOL_VERSION];
177
+ const headerVersion = h['mcp-protocol-version'];
178
+ if (typeof headerVersion !== 'string' || headerVersion.length === 0) {
179
+ return mismatch('Header mismatch: MCP-Protocol-Version header is required on modern requests');
180
+ }
181
+ if (headerVersion !== bodyVersion) {
182
+ return mismatch(
183
+ `Header mismatch: MCP-Protocol-Version header value '${headerVersion}' does not match body value '${bodyVersion}'`
184
+ );
185
+ }
186
+
187
+ const headerMethod = h['mcp-method'];
188
+ if (typeof headerMethod !== 'string' || headerMethod.length === 0) {
189
+ return mismatch('Header mismatch: Mcp-Method header is required on modern requests');
190
+ }
191
+ if (headerMethod !== msg.method) {
192
+ return mismatch(
193
+ `Header mismatch: Mcp-Method header value '${headerMethod}' does not match body value '${msg.method}'`
194
+ );
195
+ }
196
+
197
+ const nameField = NAMED_METHODS[msg.method];
198
+ if (nameField) {
199
+ const bodyName = msg.params ? msg.params[nameField] : undefined;
200
+ const rawHeaderName = h['mcp-name'];
201
+ if (typeof rawHeaderName !== 'string' || rawHeaderName.length === 0) {
202
+ return mismatch(`Header mismatch: Mcp-Name header is required for ${msg.method}`);
203
+ }
204
+ // 4.2: decode the Base64 sentinel BEFORE comparing (spec MUST) - tool names are only
205
+ // SHOULD-constrained to header-safe characters and resource URIs are not constrained at
206
+ // all, so an encoded Mcp-Name is a first-class shape, not an error.
207
+ const headerName = decodeHeaderSentinel(rawHeaderName);
208
+ if (headerName !== bodyName) {
209
+ return mismatch(
210
+ `Header mismatch: Mcp-Name header value '${rawHeaderName}' does not match body value '${bodyName}'`
211
+ );
212
+ }
213
+ }
214
+ return null;
215
+ }
216
+
217
+ /** The protocol versions this dual-era server speaks (modern first; legacy negotiated via
218
+ * initialize). Kept as a function so the legacy set is injected once by the server, not
219
+ * duplicated here. server.js seeds it via setLegacyVersion() - a LIST since 0.7.0
220
+ * (a single string is still accepted for compatibility). */
221
+ let _legacyVersions = [];
222
+ function setLegacyVersion(v) {
223
+ if (typeof v === 'string' && v.length > 0) _legacyVersions = [v];
224
+ else if (Array.isArray(v)) _legacyVersions = v.filter((x) => typeof x === 'string' && x.length > 0);
225
+ }
226
+ function supportedVersions() {
227
+ return [MODERN_PROTOCOL_VERSION].concat(_legacyVersions);
228
+ }
229
+
230
+ /**
231
+ * Decorate a result object for the modern era, in place-safe copy:
232
+ * - `resultType: 'complete'` when absent - the preserve-if-present branch below is
233
+ * LOAD-BEARING for the elicitation bridge, whose suspensions arrive here already carrying
234
+ * `resultType: 'input_required'` and must pass through undamaged (the elicitation
235
+ * bridge DOES originate input_required).
236
+ * - `_meta[serverInfo]` (SHOULD on every result).
237
+ * - cache hints (`ttlMs` + `cacheScope`, REQUIRED on the CacheableResult surfaces) when
238
+ * `cacheHints` is given.
239
+ * @param {object} result the legacy-shaped result payload (e.g. { tools: [...] })
240
+ * @param {object} serverInfo { name, version }
241
+ * @param {{ttlMs:number, cacheScope:string}} [cacheHints]
242
+ * @returns {object} a new result object carrying the modern fields
243
+ */
244
+ function decorateResult(result, serverInfo, cacheHints) {
245
+ const base = result && typeof result === 'object' && !Array.isArray(result) ? result : { value: result };
246
+ const out = Object.assign({}, base);
247
+ if (typeof out.resultType !== 'string') out.resultType = 'complete';
248
+ const meta = Object.assign({}, out._meta);
249
+ if (serverInfo && !meta[META_KEYS.SERVER_INFO]) meta[META_KEYS.SERVER_INFO] = serverInfo;
250
+ out._meta = meta;
251
+ if (cacheHints) {
252
+ if (typeof out.ttlMs !== 'number') out.ttlMs = cacheHints.ttlMs;
253
+ if (typeof out.cacheScope !== 'string') out.cacheScope = cacheHints.cacheScope;
254
+ }
255
+ return out;
256
+ }
257
+
258
+ /**
259
+ * Build the `server/discover` result (servers MUST implement it; calling it is optional and
260
+ * doubles as the spec's stdio era-probe). Answered for modern AND meta-less probes - the RC
261
+ * does not define the meta-less case, so we answer rather than reject (a probe that errors
262
+ * tells a dual-era client we are legacy-only, which would be a lie).
263
+ * @param {object} serverInfo { name, version }
264
+ * @param {object} capabilities the capability object to advertise (tools etc.)
265
+ * @param {string} [instructions] optional natural-language guidance for LLM clients
266
+ * @returns {object}
267
+ */
268
+ function discoverResult(serverInfo, capabilities, instructions) {
269
+ const out = {
270
+ resultType: 'complete',
271
+ supportedVersions: supportedVersions(),
272
+ capabilities: capabilities && typeof capabilities === 'object' ? capabilities : { tools: {} },
273
+ _meta: {},
274
+ ttlMs: CACHE_HINTS.discover.ttlMs,
275
+ cacheScope: CACHE_HINTS.discover.cacheScope,
276
+ };
277
+ if (serverInfo) out._meta[META_KEYS.SERVER_INFO] = serverInfo;
278
+ if (typeof instructions === 'string' && instructions.length > 0) out.instructions = instructions;
279
+ return out;
280
+ }
281
+
282
+ /**
283
+ * Normalise a subscriptions/listen request's notification filter into the subset the SHAPE
284
+ * supports: toolsListChanged, promptsListChanged, resourcesListChanged, resourceSubscriptions
285
+ * (per-URI). This is the SYNTACTIC pass only - whether resourceSubscriptions can actually be
286
+ * HONOURED depends on the connected upstreams (a legacy upstream needs a forwarded
287
+ * resources/subscribe), so the transports refine `agreed` against the aggregator before acking.
288
+ * The ack's `notifications` is what the server actually honours, not what was asked for.
289
+ * @param {object} params the listen request params
290
+ * @returns {{ requested: object, agreed: object }}
291
+ */
292
+ function normaliseListenFilter(params) {
293
+ const req = params && params.notifications && typeof params.notifications === 'object'
294
+ ? params.notifications
295
+ : {};
296
+ const agreed = {};
297
+ if (req.toolsListChanged === true) agreed.toolsListChanged = true;
298
+ if (req.promptsListChanged === true) agreed.promptsListChanged = true;
299
+ if (req.resourcesListChanged === true) agreed.resourcesListChanged = true;
300
+ // resourceSubscriptions: an array of resource URIs the client wants change notifications for.
301
+ if (Array.isArray(req.resourceSubscriptions) && req.resourceSubscriptions.length) {
302
+ agreed.resourceSubscriptions = req.resourceSubscriptions.filter((u) => typeof u === 'string');
303
+ }
304
+ return { requested: req, agreed };
305
+ }
306
+
307
+ /** Map a change-notification METHOD to the subscriptions/listen filter channel that gates it. */
308
+ const NOTIFICATION_CHANNEL = Object.freeze({
309
+ 'notifications/tools/list_changed': 'toolsListChanged',
310
+ 'notifications/prompts/list_changed': 'promptsListChanged',
311
+ 'notifications/resources/list_changed': 'resourcesListChanged',
312
+ 'notifications/resources/updated': 'resourceSubscriptions',
313
+ });
314
+
315
+ /**
316
+ * Does a change-notification belong to a channel this subscription AGREED to? tools/prompts/
317
+ * resources-list-changed gate on the boolean flag; resources/updated gates on the uri appearing in
318
+ * the agreed resourceSubscriptions list. A notification with no known channel is never delivered on
319
+ * a listen stream (request-scoped notifications ride their own request's stream, not this one).
320
+ * @param {object} notification a JSON-RPC notification
321
+ * @param {object} agreed the agreed filter from normaliseListenFilter
322
+ * @returns {boolean}
323
+ */
324
+ function notificationMatchesFilter(notification, agreed) {
325
+ if (!notification || typeof notification.method !== 'string' || !agreed) return false;
326
+ const channel = NOTIFICATION_CHANNEL[notification.method];
327
+ if (!channel) return false;
328
+ if (channel === 'resourceSubscriptions') {
329
+ const subs = agreed.resourceSubscriptions;
330
+ if (!Array.isArray(subs) || !subs.length) return false;
331
+ const uri = notification.params && notification.params.uri;
332
+ return typeof uri === 'string' && subs.includes(uri);
333
+ }
334
+ return agreed[channel] === true;
335
+ }
336
+
337
+ /**
338
+ * The acknowledgment notification a server MUST send as the FIRST message on a listen stream.
339
+ * @param {*} subscriptionId the JSON-RPC id of the subscriptions/listen request
340
+ * @param {object} agreed the honoured filter subset from normaliseListenFilter
341
+ * @returns {object} a JSON-RPC notification object
342
+ */
343
+ function listenAck(subscriptionId, agreed) {
344
+ return {
345
+ jsonrpc: '2.0',
346
+ method: 'notifications/subscriptions/acknowledged',
347
+ params: {
348
+ _meta: { [META_KEYS.SUBSCRIPTION_ID]: subscriptionId },
349
+ notifications: agreed || {},
350
+ },
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Tag a notification with the subscription id it belongs to (every notification on a listen
356
+ * stream MUST carry it). Returns a copy; the input is not mutated.
357
+ * @param {object} notification a JSON-RPC notification (e.g. tools/list_changed)
358
+ * @param {*} subscriptionId
359
+ * @returns {object}
360
+ */
361
+ function tagNotification(notification, subscriptionId) {
362
+ const n = notification || {};
363
+ const params = Object.assign({}, n.params);
364
+ params._meta = Object.assign({}, params._meta, { [META_KEYS.SUBSCRIPTION_ID]: subscriptionId });
365
+ return Object.assign({}, n, { params });
366
+ }
367
+
368
+ /**
369
+ * The graceful-closure result for a listen stream (the server ends a subscription by sending a
370
+ * final RESULT to the listen request's id, then closing the stream).
371
+ * @param {*} subscriptionId the listen request's JSON-RPC id
372
+ * @returns {object} a JSON-RPC result object
373
+ */
374
+ function listenClose(subscriptionId) {
375
+ return {
376
+ jsonrpc: '2.0',
377
+ id: subscriptionId,
378
+ result: {
379
+ resultType: 'complete',
380
+ _meta: { [META_KEYS.SUBSCRIPTION_ID]: subscriptionId },
381
+ },
382
+ };
383
+ }
384
+
385
+ module.exports = {
386
+ MODERN_PROTOCOL_VERSION,
387
+ META_KEYS,
388
+ ERR_MODERN,
389
+ CACHE_HINTS,
390
+ getMeta,
391
+ isModernRequest,
392
+ validateModernRequest,
393
+ validateModernHeaders,
394
+ // 4.2: exported for the transport's Mcp-Param-* handling (4.3) + tests.
395
+ decodeHeaderSentinel,
396
+ setLegacyVersion,
397
+ supportedVersions,
398
+ decorateResult,
399
+ discoverResult,
400
+ normaliseListenFilter,
401
+ notificationMatchesFilter,
402
+ NOTIFICATION_CHANNEL,
403
+ listenAck,
404
+ tagNotification,
405
+ listenClose,
406
+ };