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