@orkestrel/mcp 0.0.7 → 0.0.9

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.
@@ -1,20 +1,80 @@
1
- import { isArray, isNumber, isRecord, isString, isUndefined } from "@orkestrel/contract";
1
+ import { arrayOf, attempt, enumerableKeys, isArray, isBoolean, isNumber, isRecord, isString, isUndefined, sanitizeBudget } from "@orkestrel/contract";
2
2
  import { Emitter } from "@orkestrel/emitter";
3
- import { Tool } from "@orkestrel/agent";
3
+ import { signToken, verifyToken } from "@orkestrel/server";
4
+ import { Tool } from "@orkestrel/tool";
4
5
  //#region src/core/constants.ts
5
- /** The MCP protocol revision this server implements (the default negotiated version). */
6
- var MCP_PROTOCOL_VERSION = "2025-06-18";
6
+ /**
7
+ * The revision offered and defaulted to in the legacy `initialize` handshake.
8
+ *
9
+ * @remarks
10
+ * This is deliberately a legacy revision, and the newest one supported. 2026-07-28 is stateless
11
+ * and defines no `initialize`, so it can never be the handshake's version — a client that offers
12
+ * it is asking to negotiate a revision with no negotiation.
13
+ */
14
+ var MCP_PROTOCOL_VERSION = "2025-11-25";
15
+ /** The legacy fallback anchor used when an initialize request cannot be accepted as modern. */
16
+ var MCP_LEGACY_VERSION = "2025-06-18";
17
+ /** The modern revision offered by an unpinned client during discovery. */
18
+ var MCP_MODERN_VERSION = "2026-07-28";
7
19
  /**
8
20
  * The MCP protocol revisions this server can negotiate.
9
21
  *
10
22
  * @remarks
11
23
  * `initialize` echoes the client's requested `protocolVersion` when it appears in
12
- * this list, else falls back to {@link MCP_PROTOCOL_VERSION}. Frozen so the list is
13
- * an immutable contract. The package does not advertise `2025-03-26` because that
14
- * revision mandates JSON-RPC batching, while this package accepts only individual
15
- * JSON-RPC messages.
24
+ * this list. Frozen in client-preference and discovery-advertisement order. The
25
+ * package does not advertise `2025-03-26` because that revision mandates JSON-RPC
26
+ * batching, while this package accepts only individual JSON-RPC messages.
27
+ */
28
+ var SUPPORTED_PROTOCOL_VERSIONS = Object.freeze([
29
+ "2026-07-28",
30
+ "2025-11-25",
31
+ "2025-06-18"
32
+ ]);
33
+ /** Reserved modern `_meta` key carrying the request's protocol revision. */
34
+ var MCP_META_VERSION = "io.modelcontextprotocol/protocolVersion";
35
+ /** Reserved modern `_meta` key carrying the client's open capability record. */
36
+ var MCP_META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
37
+ /** Reserved modern `_meta` key carrying the optional client identity. */
38
+ var MCP_META_CLIENT = "io.modelcontextprotocol/clientInfo";
39
+ /** Reserved modern `_meta` key carrying the server identity on results. */
40
+ var MCP_META_SERVER = "io.modelcontextprotocol/serverInfo";
41
+ /** Reserved modern `_meta` key carrying a `subscriptions/listen` request id. */
42
+ var MCP_META_SUBSCRIPTION = "io.modelcontextprotocol/subscriptionId";
43
+ /** MCP reserved error: required HTTP metadata does not match the request body. */
44
+ var MCP_HEADER_MISMATCH = -32020;
45
+ /** MCP reserved error: an operation needs a client capability that was not declared. */
46
+ var MCP_MISSING_CAPABILITY = -32021;
47
+ /** MCP reserved error: a request names an unsupported protocol revision. */
48
+ var MCP_UNSUPPORTED_VERSION = -32022;
49
+ /**
50
+ * Default modern result freshness lifetime in milliseconds.
51
+ *
52
+ * @remarks
53
+ * `ttlMs` is required on cacheable results, while zero means immediately stale
54
+ * rather than uncached, so the neutral usable default is one minute.
55
+ */
56
+ var DEFAULT_MCP_CACHE_TTL = 6e4;
57
+ /**
58
+ * Secure server bounds used when the matching `limit` option leaf is absent or malformed.
59
+ *
60
+ * @remarks
61
+ * One MiB admits ordinary JSON-RPC requests and substantial tool arguments; 16 KiB admits
62
+ * extension-rich modern metadata and signed multi-round state; four MiB admits substantial
63
+ * JSON tool output without allowing an unconfigured service to serialize arbitrary process
64
+ * memory; 64 metadata keys admits the reserved keys plus many extensions; 128 concurrent
65
+ * streams admits a busy service while bounding retained producers; depth 32 admits ordinary
66
+ * JSON documents while rejecting stack-hostile nesting. Frozen so callers cannot alter the
67
+ * defaults observed by later servers.
16
68
  */
17
- var SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(["2025-06-18"]);
69
+ var DEFAULT_MCP_LIMITS = Object.freeze({
70
+ message: 1048576,
71
+ metadata: 16384,
72
+ keys: 64,
73
+ state: 16384,
74
+ content: 4194304,
75
+ subscriptions: 128,
76
+ depth: 32
77
+ });
18
78
  /** JSON-RPC 2.0 reserved error: invalid JSON was received (the message did not parse). */
19
79
  var JSONRPC_PARSE_ERROR = -32700;
20
80
  /** JSON-RPC 2.0 reserved error: the payload was not a valid Request object. */
@@ -34,6 +94,8 @@ var DEFAULT_MCP_CLIENT_VERSION = "1.0.0";
34
94
  * is unset — a request the remote server does not answer within it rejects.
35
95
  */
36
96
  var DEFAULT_MCP_REQUEST_TIMEOUT = 3e4;
97
+ /** The maximum discovery-probe deadline used when a client deadline is configured. */
98
+ var DEFAULT_MCP_PROBE_TIMEOUT = 50;
37
99
  //#endregion
38
100
  //#region src/core/errors.ts
39
101
  /**
@@ -44,13 +106,19 @@ var DEFAULT_MCP_REQUEST_TIMEOUT = 3e4;
44
106
  * {@link MCPClient} throws this error only for a remote JSON-RPC `error` response.
45
107
  * Local lifecycle and transport conditions such as disconnects and request timeouts
46
108
  * remain plain `Error`s. `context` carries the response's optional `error.data`
47
- * unchanged and is `undefined` when the peer omitted it.
109
+ * unchanged and is `undefined` when the peer omitted it. This includes the modern
110
+ * reserved paths: `-32020` carries no context, `-32021` may carry
111
+ * `requiredCapabilities`, and `-32022` carries the peer's `supported` revisions and
112
+ * `requested` revision for negotiation recovery.
48
113
  *
49
114
  * @example
50
115
  * ```ts
51
- * const error = new MCPError('Method not found', -32601, { method: 'missing' })
52
- * error.code // -32601
53
- * error.context // { method: 'missing' }
116
+ * const error = new MCPError('Unsupported protocol version', -32022, {
117
+ * supported: ['2026-07-28'],
118
+ * requested: '2024-11-05',
119
+ * })
120
+ * error.code // -32022
121
+ * error.context // { supported: ['2026-07-28'], requested: '2024-11-05' }
54
122
  * ```
55
123
  */
56
124
  var MCPError = class extends Error {
@@ -92,6 +160,161 @@ function isMCPError(value) {
92
160
  //#endregion
93
161
  //#region src/core/validators.ts
94
162
  /**
163
+ * Determine whether a value is a string within a UTF-8 byte bound.
164
+ *
165
+ * @param value - The unknown value to inspect
166
+ * @param bytes - The maximum accepted encoded bytes
167
+ * @returns `true` only for a string whose UTF-8 representation fits the bound
168
+ *
169
+ * @example
170
+ * ```ts
171
+ * isBoundedString('€', 3) // true
172
+ * isBoundedString('€', 2) // false
173
+ * ```
174
+ */
175
+ function isBoundedString(value, bytes) {
176
+ if (!isString(value) || !Number.isFinite(bytes) || !Number.isInteger(bytes) || bytes < 0) return false;
177
+ let measured = 0;
178
+ for (let index = 0; index < value.length; index += 1) {
179
+ const code = value.charCodeAt(index);
180
+ if (code <= 127) measured += 1;
181
+ else if (code <= 2047) measured += 2;
182
+ else if (code >= 55296 && code <= 56319) {
183
+ const next = value.charCodeAt(index + 1);
184
+ if (next >= 56320 && next <= 57343) {
185
+ measured += 4;
186
+ index += 1;
187
+ } else measured += 3;
188
+ } else measured += 3;
189
+ if (measured > bytes) return false;
190
+ }
191
+ return true;
192
+ }
193
+ /**
194
+ * Determine whether a value is bounded, cycle-free JSON with safe property names.
195
+ *
196
+ * @remarks
197
+ * Traversal is iterative, ancestor-aware, and contained by {@link attempt}; deep input,
198
+ * cycles, accessors, hostile proxies, `Map`/`Set`, and the prototype-pollution keys
199
+ * `__proto__`, `constructor`, and `prototype` return `false` rather than throwing.
200
+ * The byte count matches `JSON.stringify` without first allocating the serialization.
201
+ *
202
+ * @param value - The unknown value to inspect
203
+ * @param limits - Serialized byte, optional key, and nesting-depth bounds
204
+ * @returns `true` only for safe JSON satisfying every bound
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * isBoundedJSON({ ok: true }, { bytes: 16, keys: 1, depth: 1 }) // true
209
+ * ```
210
+ */
211
+ function isBoundedJSON(value, limits) {
212
+ const outcome = attempt(() => {
213
+ const limit = sanitizeBudget(limits.bytes, 0);
214
+ const depth = sanitizeBudget(limits.depth, 0);
215
+ const breadth = limits.keys === void 0 ? void 0 : sanitizeBudget(limits.keys, 0);
216
+ let bytes = 0;
217
+ let keys = 0;
218
+ const ancestors = /* @__PURE__ */ new WeakSet();
219
+ const pending = [{
220
+ value,
221
+ depth: 0,
222
+ closing: false
223
+ }];
224
+ while (pending.length > 0) {
225
+ const frame = pending.pop();
226
+ if (frame === void 0) return false;
227
+ const entry = frame.value;
228
+ if (frame.closing) {
229
+ if (typeof entry !== "object" || entry === null) return false;
230
+ ancestors.delete(entry);
231
+ continue;
232
+ }
233
+ if (frame.depth > depth) return false;
234
+ if (entry === null) bytes += 4;
235
+ else if (isBoolean(entry)) bytes += entry ? 4 : 5;
236
+ else if (isNumber(entry)) bytes += Number.isFinite(entry) ? String(entry).length : 4;
237
+ else if (isString(entry)) {
238
+ bytes += 2;
239
+ if (bytes > limit) return false;
240
+ for (let index = 0; index < entry.length; index += 1) {
241
+ const code = entry.charCodeAt(index);
242
+ if (code === 34 || code === 92 || code === 8 || code === 9 || code === 10 || code === 12 || code === 13) bytes += 2;
243
+ else if (code <= 31) bytes += 6;
244
+ else if (code <= 127) bytes += 1;
245
+ else if (code <= 2047) bytes += 2;
246
+ else if (code >= 55296 && code <= 56319) {
247
+ const next = entry.charCodeAt(index + 1);
248
+ if (next >= 56320 && next <= 57343) {
249
+ bytes += 4;
250
+ index += 1;
251
+ } else bytes += 6;
252
+ } else if (code >= 56320 && code <= 57343) bytes += 6;
253
+ else bytes += 3;
254
+ if (bytes > limit) return false;
255
+ }
256
+ continue;
257
+ } else if (typeof entry === "object") {
258
+ if (ancestors.has(entry)) return false;
259
+ const names = enumerableKeys(entry);
260
+ if (names === void 0) return false;
261
+ for (const name of names) if (name === "__proto__" || name === "constructor" || name === "prototype") return false;
262
+ if (Array.isArray(entry)) {
263
+ bytes += 2 + Math.max(0, entry.length - 1);
264
+ if (bytes > limit || names.length !== entry.length) return false;
265
+ ancestors.add(entry);
266
+ pending.push({
267
+ value: entry,
268
+ depth: frame.depth,
269
+ closing: true
270
+ });
271
+ for (let index = entry.length - 1; index >= 0; index -= 1) {
272
+ const descriptor = Object.getOwnPropertyDescriptor(entry, String(index));
273
+ if (descriptor === void 0 || !Object.hasOwn(descriptor, "value")) return false;
274
+ pending.push({
275
+ value: descriptor.value,
276
+ depth: frame.depth + 1,
277
+ closing: false
278
+ });
279
+ }
280
+ continue;
281
+ }
282
+ if (!isRecord(entry)) return false;
283
+ keys += names.length;
284
+ if (breadth !== void 0 && keys > breadth) return false;
285
+ bytes += 2 + Math.max(0, names.length - 1) + names.length;
286
+ if (bytes > limit) return false;
287
+ ancestors.add(entry);
288
+ pending.push({
289
+ value: entry,
290
+ depth: frame.depth,
291
+ closing: true
292
+ });
293
+ for (let index = names.length - 1; index >= 0; index -= 1) {
294
+ const name = names[index];
295
+ if (name === void 0) return false;
296
+ const descriptor = Object.getOwnPropertyDescriptor(entry, name);
297
+ if (descriptor === void 0 || !Object.hasOwn(descriptor, "value")) return false;
298
+ pending.push({
299
+ value: descriptor.value,
300
+ depth: frame.depth + 1,
301
+ closing: false
302
+ });
303
+ pending.push({
304
+ value: name,
305
+ depth: frame.depth,
306
+ closing: false
307
+ });
308
+ }
309
+ continue;
310
+ } else return false;
311
+ if (bytes > limit) return false;
312
+ }
313
+ return bytes <= limit;
314
+ });
315
+ return outcome.success && outcome.value;
316
+ }
317
+ /**
95
318
  * Determine whether a value is a valid JSON-RPC REQUEST `id` — a string, a number,
96
319
  * or absent.
97
320
  *
@@ -115,6 +338,263 @@ function isRequestId(value) {
115
338
  return isUndefined(value) || isString(value) || isNumber(value);
116
339
  }
117
340
  /**
341
+ * Determine whether a value is a supported {@link MCPVersion}.
342
+ *
343
+ * @param value - The unknown value to inspect
344
+ * @returns `true` when the value is one of {@link SUPPORTED_PROTOCOL_VERSIONS}
345
+ */
346
+ function isMCPVersion(value) {
347
+ return isString(value) && SUPPORTED_PROTOCOL_VERSIONS.some((version) => version === value);
348
+ }
349
+ /**
350
+ * Determine whether a value is an MCP {@link SubscriptionFilter}.
351
+ *
352
+ * @remarks
353
+ * Every filter field is optional. Boolean notification families accept only booleans, and
354
+ * `resourceSubscriptions` accepts only an array of string URIs. Unknown fields remain open
355
+ * for protocol extensions and are ignored by the built-in subscription matcher. Total over
356
+ * hostile input.
357
+ *
358
+ * @param value - The unknown value to inspect
359
+ * @returns `true` when every recognized filter field has its protocol shape
360
+ */
361
+ function isSubscriptionFilter(value) {
362
+ if (!isRecord(value)) return false;
363
+ const tools = value["toolsListChanged"];
364
+ if (!isUndefined(tools) && !isBoolean(tools)) return false;
365
+ const prompts = value["promptsListChanged"];
366
+ if (!isUndefined(prompts) && !isBoolean(prompts)) return false;
367
+ const resources = value["resourcesListChanged"];
368
+ if (!isUndefined(resources) && !isBoolean(resources)) return false;
369
+ const subscriptions = value["resourceSubscriptions"];
370
+ return isUndefined(subscriptions) || arrayOf(isString)(subscriptions);
371
+ }
372
+ /**
373
+ * Determine whether a client capability record declares form-mode elicitation.
374
+ *
375
+ * @remarks
376
+ * The protocol's empty `elicitation` object is the implicit form-only declaration.
377
+ * A non-empty declaration must carry a record-valued `form` member; URL-only support
378
+ * does not authorize a form request. Total over hostile input.
379
+ *
380
+ * @param value - The client capability record to inspect
381
+ * @returns `true` when form-mode elicitation is declared
382
+ *
383
+ * @example
384
+ * ```ts
385
+ * isFormElicitationSupported({ elicitation: {} }) // true — implicit form mode
386
+ * isFormElicitationSupported({ elicitation: { url: {} } }) // false
387
+ * ```
388
+ */
389
+ function isFormElicitationSupported(value) {
390
+ try {
391
+ if (!isRecord(value)) return false;
392
+ const elicitation = value["elicitation"];
393
+ if (!isRecord(elicitation)) return false;
394
+ if (isRecord(elicitation["form"])) return true;
395
+ return Object.keys(elicitation).length === 0;
396
+ } catch {
397
+ return false;
398
+ }
399
+ }
400
+ /**
401
+ * Determine whether a value is one restricted primitive form-elicitation schema.
402
+ *
403
+ * @param value - The unknown value to inspect
404
+ * @returns `true` for a supported boolean, numeric, string, or string-array schema
405
+ *
406
+ * @example
407
+ * ```ts
408
+ * isElicitPrimitiveSchema({ type: 'boolean', default: true }) // true
409
+ * isElicitPrimitiveSchema({ type: 'object' }) // false
410
+ * ```
411
+ */
412
+ function isElicitPrimitiveSchema(value) {
413
+ try {
414
+ if (!isRecord(value)) return false;
415
+ const title = value["title"];
416
+ const description = value["description"];
417
+ if (!isUndefined(title) && !isString(title)) return false;
418
+ if (!isUndefined(description) && !isString(description)) return false;
419
+ const fallback = value["default"];
420
+ if (value["type"] === "boolean") return isUndefined(fallback) || isBoolean(fallback);
421
+ if (value["type"] === "number" || value["type"] === "integer") {
422
+ const minimum = value["minimum"];
423
+ const maximum = value["maximum"];
424
+ return (isUndefined(minimum) || isNumber(minimum)) && (isUndefined(maximum) || isNumber(maximum)) && (isUndefined(fallback) || isNumber(fallback));
425
+ }
426
+ if (value["type"] === "string") {
427
+ const minimum = value["minLength"];
428
+ const maximum = value["maxLength"];
429
+ const format = value["format"];
430
+ const choices = value["enum"];
431
+ const names = value["enumNames"];
432
+ const titled = value["oneOf"];
433
+ return (isUndefined(minimum) || isNumber(minimum)) && (isUndefined(maximum) || isNumber(maximum)) && (isUndefined(format) || format === "uri" || format === "email" || format === "date" || format === "date-time") && (isUndefined(fallback) || isString(fallback)) && (isUndefined(choices) || arrayOf(isString)(choices)) && (isUndefined(names) || arrayOf(isString)(names)) && (isUndefined(titled) || arrayOf(isRecord)(titled) && titled.every((choice) => isString(choice["const"]) && isString(choice["title"])));
434
+ }
435
+ if (value["type"] !== "array") return false;
436
+ const minimum = value["minItems"];
437
+ const maximum = value["maxItems"];
438
+ const items = value["items"];
439
+ if (!isUndefined(minimum) && !isNumber(minimum) || !isUndefined(maximum) && !isNumber(maximum) || !isUndefined(fallback) && !arrayOf(isString)(fallback) || !isRecord(items)) return false;
440
+ if (items["type"] === "string") return arrayOf(isString)(items["enum"]);
441
+ const choices = items["anyOf"];
442
+ return arrayOf(isRecord)(choices) && choices.every((choice) => isString(choice["const"]) && isString(choice["title"]));
443
+ } catch {
444
+ return false;
445
+ }
446
+ }
447
+ /**
448
+ * Determine whether a value is a form-mode elicitation parameter object.
449
+ *
450
+ * @param value - The unknown value to inspect
451
+ * @returns `true` when `value` has the restricted form elicitation shape
452
+ *
453
+ * @example
454
+ * ```ts
455
+ * isElicitRequestFormParams({
456
+ * message: 'Continue?',
457
+ * requestedSchema: { type: 'object', properties: {} },
458
+ * }) // true
459
+ * ```
460
+ */
461
+ function isElicitRequestFormParams(value) {
462
+ try {
463
+ if (!isRecord(value)) return false;
464
+ const mode = value["mode"];
465
+ if (!isUndefined(mode) && mode !== "form") return false;
466
+ if (!isString(value["message"])) return false;
467
+ const schema = value["requestedSchema"];
468
+ if (!isRecord(schema) || schema["type"] !== "object" || !isRecord(schema["properties"])) return false;
469
+ const dialect = schema["$schema"];
470
+ if (!isUndefined(dialect) && !isString(dialect)) return false;
471
+ const required = schema["required"];
472
+ return (isUndefined(required) || arrayOf(isString)(required)) && Object.values(schema["properties"]).every((property) => isElicitPrimitiveSchema(property));
473
+ } catch {
474
+ return false;
475
+ }
476
+ }
477
+ /**
478
+ * Determine whether a value is a URL-mode elicitation parameter object.
479
+ *
480
+ * @param value - The unknown value to inspect
481
+ * @returns `true` when `value` has the URL elicitation shape
482
+ *
483
+ * @example
484
+ * ```ts
485
+ * isElicitRequestURLParams({ mode: 'url', message: 'Authenticate', url: 'https://example.test' })
486
+ * ```
487
+ */
488
+ function isElicitRequestURLParams(value) {
489
+ return isRecord(value) && value["mode"] === "url" && isString(value["message"]) && isString(value["url"]);
490
+ }
491
+ /**
492
+ * Determine whether a value is an embedded `elicitation/create` request.
493
+ *
494
+ * @param value - The unknown value to inspect
495
+ * @returns `true` when `value` is a form- or URL-mode elicitation request
496
+ *
497
+ * @example
498
+ * ```ts
499
+ * isElicitRequest({
500
+ * method: 'elicitation/create',
501
+ * params: { message: 'Continue?', requestedSchema: { type: 'object', properties: {} } },
502
+ * }) // true
503
+ * ```
504
+ */
505
+ function isElicitRequest(value) {
506
+ if (!isRecord(value) || value["method"] !== "elicitation/create") return false;
507
+ return isElicitRequestFormParams(value["params"]) || isElicitRequestURLParams(value["params"]);
508
+ }
509
+ /**
510
+ * Determine whether a value is one legal embedded multi-round-trip request.
511
+ *
512
+ * @param value - The unknown value to inspect
513
+ * @returns `true` for elicitation, deprecated sampling, or deprecated roots requests
514
+ *
515
+ * @example
516
+ * ```ts
517
+ * isInputRequest({ method: 'roots/list' }) // true — legal but not produced by this package
518
+ * ```
519
+ */
520
+ function isInputRequest(value) {
521
+ if (isElicitRequest(value)) return true;
522
+ if (!isRecord(value)) return false;
523
+ const params = value["params"];
524
+ if (value["method"] === "sampling/createMessage") return isRecord(params);
525
+ return value["method"] === "roots/list" && (isUndefined(params) || isRecord(params));
526
+ }
527
+ /**
528
+ * Determine whether a value is a server-keyed map of embedded input requests.
529
+ *
530
+ * @param value - The unknown value to inspect
531
+ * @returns `true` when every own value is a legal {@link InputRequest}
532
+ *
533
+ * @example
534
+ * ```ts
535
+ * isInputRequests({ confirm: { method: 'roots/list' } }) // true; maps, never arrays
536
+ * ```
537
+ */
538
+ function isInputRequests(value) {
539
+ try {
540
+ return isRecord(value) && Object.values(value).every((request) => isInputRequest(request));
541
+ } catch {
542
+ return false;
543
+ }
544
+ }
545
+ /**
546
+ * Determine whether a value is one elicitation response.
547
+ *
548
+ * @param value - The unknown value to inspect
549
+ * @returns `true` when action/content have the protocol shape
550
+ *
551
+ * @example
552
+ * ```ts
553
+ * isElicitResult({ action: 'accept', content: { approved: true } }) // true
554
+ * ```
555
+ */
556
+ function isElicitResult(value) {
557
+ try {
558
+ if (!isRecord(value)) return false;
559
+ const action = value["action"];
560
+ if (action !== "accept" && action !== "decline" && action !== "cancel") return false;
561
+ const content = value["content"];
562
+ if (isUndefined(content)) return true;
563
+ if (!isRecord(content)) return false;
564
+ return Object.values(content).every((item) => isString(item) || isNumber(item) || isBoolean(item) || arrayOf(isString)(item));
565
+ } catch {
566
+ return false;
567
+ }
568
+ }
569
+ /**
570
+ * Determine whether a value is an MCP input-required result.
571
+ *
572
+ * @remarks
573
+ * Enforces the at-least-one-of rule at runtime: `inputRequests`, `requestState`, or
574
+ * both must be present and valid. Total over hostile input.
575
+ *
576
+ * @param value - The unknown value to inspect
577
+ * @returns `true` when `value` is a valid input-required result
578
+ *
579
+ * @example
580
+ * ```ts
581
+ * isInputRequiredResult({ resultType: 'input_required', requestState: 'opaque' }) // true
582
+ * isInputRequiredResult({ resultType: 'input_required' }) // false
583
+ * ```
584
+ */
585
+ function isInputRequiredResult(value) {
586
+ try {
587
+ if (!isRecord(value) || value["resultType"] !== "input_required") return false;
588
+ const inputRequests = value["inputRequests"];
589
+ const requestState = value["requestState"];
590
+ if (!isUndefined(inputRequests) && !isInputRequests(inputRequests)) return false;
591
+ if (!isUndefined(requestState) && !isString(requestState)) return false;
592
+ return !isUndefined(inputRequests) || !isUndefined(requestState);
593
+ } catch {
594
+ return false;
595
+ }
596
+ }
597
+ /**
118
598
  * Determine whether a parsed value is a {@link JSONRPCRequest}.
119
599
  *
120
600
  * @remarks
@@ -193,6 +673,28 @@ function isJSONRPCMessage(value) {
193
673
  function isInitializeRequest(value) {
194
674
  return isJSONRPCRequest(value) && value.method === "initialize";
195
675
  }
676
+ /**
677
+ * Determine whether a JSON-RPC request uses the modern per-request MCP wire shape.
678
+ *
679
+ * @remarks
680
+ * Presence routes and validity answers: this guard checks only that
681
+ * `params._meta` carries the reserved protocol-version key. The key's value is
682
+ * deliberately not narrowed here, so a present non-string version remains modern
683
+ * and is rejected later by `parseRequestContext` rather than falling through to
684
+ * legacy dispatch. Total over hostile and malformed input.
685
+ *
686
+ * @param value - The already-parsed value to inspect
687
+ * @returns `true` when the value is a request carrying the reserved version key
688
+ */
689
+ function isModernRequest(value) {
690
+ try {
691
+ if (!isJSONRPCRequest(value)) return false;
692
+ const metadata = value.params?.["_meta"];
693
+ return isRecord(metadata) && Object.hasOwn(metadata, "io.modelcontextprotocol/protocolVersion");
694
+ } catch {
695
+ return false;
696
+ }
697
+ }
196
698
  //#endregion
197
699
  //#region src/core/parsers.ts
198
700
  /**
@@ -218,6 +720,122 @@ function isInitializeRequest(value) {
218
720
  function parseJSONRPCMessage(value) {
219
721
  return isJSONRPCMessage(value) ? value : void 0;
220
722
  }
723
+ /**
724
+ * Parse the reserved modern request metadata into an {@link MCPRequestContext}.
725
+ *
726
+ * @remarks
727
+ * This is the validity step after {@link isModernRequest}: a defined result can
728
+ * only come from a guard-positive request, while a guard-positive request returns
729
+ * `undefined` exactly when its required modern metadata is malformed. The version
730
+ * must be a string but need not be supported; unsupported strings belong to the
731
+ * dedicated protocol-version error path. Client identity is optional, but when
732
+ * present it must carry string `name` and `version` members. Total over hostile and
733
+ * malformed input.
734
+ *
735
+ * @param value - The already-parsed request candidate to coerce
736
+ * @returns The validated modern request context, or `undefined`
737
+ */
738
+ function parseRequestContext(value) {
739
+ try {
740
+ if (!isModernRequest(value)) return void 0;
741
+ const metadata = value.params?.["_meta"];
742
+ if (!isRecord(metadata)) return void 0;
743
+ const version = metadata[MCP_META_VERSION];
744
+ const capabilities = metadata[MCP_META_CAPABILITIES];
745
+ if (!isString(version) || !isRecord(capabilities)) return void 0;
746
+ const client = metadata[MCP_META_CLIENT];
747
+ if (client === void 0) return {
748
+ version,
749
+ capabilities
750
+ };
751
+ if (!isRecord(client)) return void 0;
752
+ const name = client["name"];
753
+ const clientVersion = client["version"];
754
+ if (!isString(name) || !isString(clientVersion)) return void 0;
755
+ return {
756
+ version,
757
+ capabilities,
758
+ identity: {
759
+ name,
760
+ version: clientVersion
761
+ }
762
+ };
763
+ } catch {
764
+ return;
765
+ }
766
+ }
767
+ /**
768
+ * Parse the verified value embedded in an opaque signed `requestState` token.
769
+ *
770
+ * @remarks
771
+ * This parser does not verify the HMAC; {@link import('@orkestrel/server').verifyToken}
772
+ * performs that boundary first and returns the JSON string parsed here. The protected
773
+ * payload binds the authenticated principal, token lifetime, originating request id,
774
+ * server-assigned input key, tool name, and optional consumer state. Total over malformed
775
+ * or hostile input.
776
+ *
777
+ * @param value - The HMAC-verified token value to parse
778
+ * @returns The protected input state, or `undefined` when malformed
779
+ *
780
+ * @example
781
+ * ```ts
782
+ * parseMCPInputState('{"principal":"user-1","ttl":1000,"origin":1,"key":"k","name":"reply"}')
783
+ * // { principal: 'user-1', ttl: 1000, origin: 1, key: 'k', name: 'reply' }
784
+ * ```
785
+ */
786
+ function parseMCPInputState(value) {
787
+ try {
788
+ if (!isString(value)) return void 0;
789
+ const parsed = JSON.parse(value);
790
+ if (!isRecord(parsed)) return void 0;
791
+ const principal = parsed["principal"];
792
+ const ttl = parsed["ttl"];
793
+ const origin = parsed["origin"];
794
+ const key = parsed["key"];
795
+ const name = parsed["name"];
796
+ const state = parsed["state"];
797
+ if (!isString(principal) || !isNumber(ttl) || !Number.isFinite(ttl)) return void 0;
798
+ if (!isString(origin) && !isNumber(origin)) return void 0;
799
+ if (!isString(key) || !isString(name)) return void 0;
800
+ if (!isUndefined(state) && !isString(state)) return void 0;
801
+ return {
802
+ principal,
803
+ ttl,
804
+ origin,
805
+ key,
806
+ name,
807
+ ...isString(state) ? { state } : {}
808
+ };
809
+ } catch {
810
+ return;
811
+ }
812
+ }
813
+ //#endregion
814
+ //#region src/core/inferers.ts
815
+ /**
816
+ * Infer the wire era for an MCP protocol revision.
817
+ *
818
+ * @param version - The protocol revision to classify
819
+ * @returns `'modern'` for `2026-07-28`, `'legacy'` for either supported legacy
820
+ * revision, or `undefined` when the revision is unsupported
821
+ */
822
+ function inferEra(version) {
823
+ switch (version) {
824
+ case "2026-07-28": return "modern";
825
+ case "2025-11-25":
826
+ case "2025-06-18": return "legacy";
827
+ default: return;
828
+ }
829
+ }
830
+ /**
831
+ * Infer the newest supported protocol revision present in a peer's offer.
832
+ *
833
+ * @param offered - The protocol revisions offered by the peer
834
+ * @returns The newest locally supported offered revision, or `undefined`
835
+ */
836
+ function inferVersion(offered) {
837
+ for (const version of SUPPORTED_PROTOCOL_VERSIONS) if (offered.includes(version)) return version;
838
+ }
221
839
  //#endregion
222
840
  //#region src/core/helpers.ts
223
841
  /**
@@ -228,7 +846,7 @@ function parseJSONRPCMessage(value) {
228
846
  * @param result - The method's return value
229
847
  * @returns The success response envelope
230
848
  */
231
- function jsonRPCResult(id, result) {
849
+ function buildJSONRPCResult(id, result) {
232
850
  return {
233
851
  jsonrpc: "2.0",
234
852
  id,
@@ -245,7 +863,7 @@ function jsonRPCResult(id, result) {
245
863
  * @param data - An OPTIONAL machine-readable payload (omitted from the envelope when absent)
246
864
  * @returns The error response envelope
247
865
  */
248
- function jsonRPCError(id, code, message, data) {
866
+ function buildJSONRPCError(id, code, message, data) {
249
867
  return {
250
868
  jsonrpc: "2.0",
251
869
  id,
@@ -264,7 +882,7 @@ function jsonRPCError(id, code, message, data) {
264
882
  * — renaming `parameters` to the wire's `inputSchema`.
265
883
  *
266
884
  * @remarks
267
- * Each {@link import('@orkestrel/agent').ToolDefinition} carries through its
885
+ * Each {@link import('@orkestrel/tool').ToolDefinition} carries through its
268
886
  * `name` and (when present) `description`; its open JSON-Schema `parameters`
269
887
  * becomes `inputSchema`, defaulting to an empty object schema (`{ type: 'object' }`)
270
888
  * when a tool declares none (MCP requires an `inputSchema`).
@@ -283,32 +901,154 @@ function buildToolDescriptors(manager) {
283
901
  });
284
902
  }
285
903
  /**
286
- * Map an executed tool's {@link ToolResult} to an MCP {@link MCPToolResult} — the
287
- * value (or error) as a `text` content block.
904
+ * Map an executed tool's {@link ToolResult} to an MCP {@link MCPCallResult} — the
905
+ * value as structured content plus a backwards-compatible `text` block, or the
906
+ * error as a `text` block.
288
907
  *
289
908
  * @remarks
290
- * The {@link ToolManagerInterface} already isolates a thrown tool into
291
- * `result.error` (so the server adds NO try/catch around `execute`): when `error`
292
- * is present, this builds an `isError: true` result carrying the error text, so the
909
+ * The {@link ToolManagerInterface} already isolates a thrown tool into a
910
+ * `success: false` result (so the server adds NO try/catch around `execute`):
911
+ * that branch builds an `isError: true` result carrying `result.error`, so the
293
912
  * model sees the failure as a tool result it can react to rather than a protocol
294
- * error; otherwise it serializes `result.value` (via `JSON.stringify`) into one
295
- * `text` block.
913
+ * error; a valued `success: true` branch carries `result.value` unchanged as
914
+ * `structuredContent` and serializes it (via `JSON.stringify`) into one `text`
915
+ * block. A value-less success retains the required empty `content` block and
916
+ * omits `structuredContent`.
296
917
  *
297
918
  * @param result - The tool's execution outcome
298
919
  * @returns The MCP tool-call result
299
920
  */
300
- function buildToolResult(result) {
301
- if (result.error !== void 0) return {
921
+ function buildCallResult(result) {
922
+ if (!result.success) return {
302
923
  content: [{
303
924
  type: "text",
304
925
  text: result.error
305
926
  }],
306
927
  isError: true
307
928
  };
308
- return { content: [{
929
+ if (result.value === void 0) return { content: [{
309
930
  type: "text",
310
- text: result.value === void 0 ? "" : JSON.stringify(result.value)
931
+ text: ""
311
932
  }] };
933
+ return {
934
+ content: [{
935
+ type: "text",
936
+ text: JSON.stringify(result.value)
937
+ }],
938
+ structuredContent: result.value
939
+ };
940
+ }
941
+ function buildModernResult(result, identity, ttl, scope) {
942
+ const currentMetadata = isRecord(result) ? result["_meta"] : void 0;
943
+ const metadata = {
944
+ ...isRecord(currentMetadata) ? currentMetadata : {},
945
+ [MCP_META_SERVER]: identity
946
+ };
947
+ if (ttl === void 0) return {
948
+ ...result,
949
+ resultType: "complete",
950
+ _meta: metadata
951
+ };
952
+ return {
953
+ ...result,
954
+ resultType: "complete",
955
+ ttlMs: ttl,
956
+ cacheScope: scope ?? "private",
957
+ _meta: metadata
958
+ };
959
+ }
960
+ /**
961
+ * Intersect a requested subscription filter with the notification families a server supports.
962
+ *
963
+ * @param requested - The notification families requested by the client
964
+ * @param supported - The notification families the server can actually produce
965
+ * @returns The exact subset the server will honour
966
+ */
967
+ function buildSubscriptionFilter(requested, supported) {
968
+ const toolsListChanged = requested.toolsListChanged === true && supported.toolsListChanged === true;
969
+ const promptsListChanged = requested.promptsListChanged === true && supported.promptsListChanged === true;
970
+ const resourcesListChanged = requested.resourcesListChanged === true && supported.resourcesListChanged === true;
971
+ const supportedResources = new Set(supported.resourceSubscriptions ?? []);
972
+ const resourceSubscriptions = requested.resourceSubscriptions?.filter((uri) => supportedResources.has(uri));
973
+ return {
974
+ ...toolsListChanged ? { toolsListChanged: true } : {},
975
+ ...promptsListChanged ? { promptsListChanged: true } : {},
976
+ ...resourcesListChanged ? { resourcesListChanged: true } : {},
977
+ ...resourceSubscriptions !== void 0 && resourceSubscriptions.length > 0 ? { resourceSubscriptions } : {}
978
+ };
979
+ }
980
+ /**
981
+ * Determine whether a produced notification belongs to an honoured subscription filter.
982
+ *
983
+ * @param notification - The server notification offered by the configured producer
984
+ * @param filter - The filter acknowledged to the client
985
+ * @returns `true` when the notification belongs on this subscription stream
986
+ */
987
+ function matchesSubscriptionNotification(notification, filter) {
988
+ if (notification.method === "notifications/tools/list_changed") return filter.toolsListChanged === true;
989
+ if (notification.method === "notifications/prompts/list_changed") return filter.promptsListChanged === true;
990
+ if (notification.method === "notifications/resources/list_changed") return filter.resourcesListChanged === true;
991
+ if (notification.method !== "notifications/resources/updated") return false;
992
+ const uri = notification.params?.["uri"];
993
+ return typeof uri === "string" && filter.resourceSubscriptions?.includes(uri) === true;
994
+ }
995
+ /**
996
+ * Stamp a subscription notification with the request id reserved for its held-open stream.
997
+ *
998
+ * @param notification - The notification to copy and stamp
999
+ * @param id - The `subscriptions/listen` request id
1000
+ * @returns The stamped notification, preserving its other params and metadata
1001
+ */
1002
+ function stampSubscriptionNotification(notification, id) {
1003
+ const metadata = notification.params?.["_meta"];
1004
+ return {
1005
+ jsonrpc: notification.jsonrpc,
1006
+ method: notification.method,
1007
+ params: {
1008
+ ...notification.params,
1009
+ _meta: {
1010
+ ...isRecord(metadata) ? metadata : {},
1011
+ [MCP_META_SUBSCRIPTION]: id
1012
+ }
1013
+ }
1014
+ };
1015
+ }
1016
+ /**
1017
+ * Build the first notification carrying a subscription id for a listen request.
1018
+ *
1019
+ * @param notifications - The exact notification filter the server will honour
1020
+ * @param id - The `subscriptions/listen` request id
1021
+ * @returns The stamped subscription acknowledgement notification
1022
+ */
1023
+ function buildSubscriptionAcknowledgement(notifications, id) {
1024
+ return stampSubscriptionNotification({
1025
+ jsonrpc: "2.0",
1026
+ method: "notifications/subscriptions/acknowledged",
1027
+ params: { notifications }
1028
+ }, id);
1029
+ }
1030
+ /**
1031
+ * Build the terminating response for a subscription source that closes gracefully.
1032
+ *
1033
+ * @param id - The `subscriptions/listen` request id
1034
+ * @param identity - The server identity included by the modern result stamping site
1035
+ * @returns The complete modern result carrying the required subscription id metadata
1036
+ */
1037
+ function buildSubscriptionResult(id, identity) {
1038
+ return buildJSONRPCResult(id, buildModernResult({ _meta: { [MCP_META_SUBSCRIPTION]: id } }, identity));
1039
+ }
1040
+ /**
1041
+ * Build the mandatory modern `server/discover` result.
1042
+ *
1043
+ * @param options - The server identity, instructions, and cache configuration
1044
+ * @returns The supported revisions, tools capability, and required modern cache stamps
1045
+ */
1046
+ function buildDiscoverResult(options) {
1047
+ return buildModernResult({
1048
+ supportedVersions: SUPPORTED_PROTOCOL_VERSIONS.filter(isMCPVersion),
1049
+ capabilities: { tools: {} },
1050
+ ...options.instructions === void 0 ? {} : { instructions: options.instructions }
1051
+ }, options.identity, options.cache?.ttl ?? 6e4, options.cache?.scope);
312
1052
  }
313
1053
  /**
314
1054
  * Build the MCP `initialize` result — the negotiated protocol version, the
@@ -316,7 +1056,8 @@ function buildToolResult(result) {
316
1056
  *
317
1057
  * @remarks
318
1058
  * Version negotiation echoes the client's `requested` version when it is one of the
319
- * {@link SUPPORTED_PROTOCOL_VERSIONS}, else falls back to {@link MCP_PROTOCOL_VERSION}.
1059
+ * supported legacy revisions. A modern or unsupported request receives the newest
1060
+ * supported legacy revision; the client decides whether to continue.
320
1061
  * `capabilities.tools` is an empty object — this server advertises the tools
321
1062
  * capability with no sub-options (no list-changed notification yet).
322
1063
  *
@@ -325,9 +1066,10 @@ function buildToolResult(result) {
325
1066
  * @param requested - The client's requested protocol version (negotiated when supported)
326
1067
  * @returns The `initialize` result payload
327
1068
  */
328
- function initializeResult(name, version, requested) {
1069
+ function buildInitializeResult(name, version, requested) {
1070
+ const newestLegacy = SUPPORTED_PROTOCOL_VERSIONS.find((candidate) => inferEra(candidate) === "legacy") ?? "2025-06-18";
329
1071
  return {
330
- protocolVersion: requested !== void 0 && SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : MCP_PROTOCOL_VERSION,
1072
+ protocolVersion: isMCPVersion(requested) && inferEra(requested) === "legacy" ? requested : newestLegacy,
331
1073
  capabilities: { tools: {} },
332
1074
  serverInfo: {
333
1075
  name,
@@ -336,6 +1078,64 @@ function initializeResult(name, version, requested) {
336
1078
  };
337
1079
  }
338
1080
  /**
1081
+ * Serialize a typed {@link MCPStream} into its string mirror — each yielded
1082
+ * notification and the terminating response, already `JSON.stringify`d.
1083
+ *
1084
+ * @remarks
1085
+ * The string-boundary half of the held-open arm: `handle` returns this so a transport
1086
+ * writes each message with no second parse, exactly as it writes a unary reply string.
1087
+ * The terminating response arrives as the returned generator's OWN `return` value, so a
1088
+ * consumer distinguishes "one more notification" from "this is the answer" without a
1089
+ * sentinel.
1090
+ *
1091
+ * @param stream - The typed held-open result to serialize
1092
+ * @returns The same sequence with every message serialized to a string
1093
+ *
1094
+ * @example
1095
+ * ```ts
1096
+ * const text = serializeStream(stream)
1097
+ * for (let next = await text.next(); ; next = await text.next()) {
1098
+ * if (next.done === true) return next.value // the terminating response, serialized
1099
+ * log(next.value) // one serialized notification
1100
+ * }
1101
+ * ```
1102
+ */
1103
+ async function* serializeStream(stream) {
1104
+ let next = await stream.next();
1105
+ while (!next.done) {
1106
+ yield JSON.stringify(next.value);
1107
+ next = await stream.next();
1108
+ }
1109
+ return JSON.stringify(next.value);
1110
+ }
1111
+ /**
1112
+ * Pump an {@link MCPTextStream} onto a transport — every notification in order, then the
1113
+ * terminating response.
1114
+ *
1115
+ * @remarks
1116
+ * The generator's `return` value is a message like any other on the wire: it is sent
1117
+ * LAST and closes the exchange. Sends are awaited one at a time so the transport
1118
+ * receives the sequence in the order the method produced it.
1119
+ *
1120
+ * @param stream - The serialized held-open result to write out
1121
+ * @param transport - The duplex channel to write each message to
1122
+ * @returns Resolves once the terminating response has been sent
1123
+ *
1124
+ * @example
1125
+ * ```ts
1126
+ * const answer = await server.handle(message)
1127
+ * if (typeof answer !== 'string') await sendStream(answer, transport)
1128
+ * ```
1129
+ */
1130
+ async function sendStream(stream, transport) {
1131
+ let next = await stream.next();
1132
+ while (!next.done) {
1133
+ await transport.send(next.value);
1134
+ next = await stream.next();
1135
+ }
1136
+ await transport.send(next.value);
1137
+ }
1138
+ /**
339
1139
  * Pipe an {@link MCPTransportInterface} into an {@link MCPServerInterface} — every
340
1140
  * inbound message runs through `server.handle`, and a defined reply is written back
341
1141
  * via `transport.send`.
@@ -343,7 +1143,11 @@ function initializeResult(name, version, requested) {
343
1143
  * @remarks
344
1144
  * `server.handle` already turns a malformed message into a serialized `-32700` /
345
1145
  * `-32600` reply and a notification into `undefined` (no reply), so this binder adds
346
- * no parsing of its own. A `transport.send` throw or rejection is caught and routed
1146
+ * no parsing of its own. A HELD-OPEN reply arrives as an
1147
+ * {@link import('./types.js').MCPTextStream} instead of a string: this is the one place
1148
+ * that pumps it, writing each notification in order and then the generator's returned
1149
+ * terminating response ({@link sendStream}). A `transport.send` throw or rejection —
1150
+ * mid-stream included — is caught and routed
347
1151
  * to `server.emitter`'s `error` event (never rethrown, never an unhandled rejection);
348
1152
  * a listener on that event that itself throws is swallowed (the end of the line —
349
1153
  * the caller's own bug, never this binder's). The returned unbind DETACHES this
@@ -372,8 +1176,10 @@ function bindServer(server, transport) {
372
1176
  transport.listen(async (message) => {
373
1177
  if (!active) return;
374
1178
  try {
375
- const response = await server.handle(message);
376
- if (response !== void 0) await transport.send(response);
1179
+ const answer = await server.handle(message);
1180
+ if (answer === void 0) return;
1181
+ if (typeof answer === "string") await transport.send(answer);
1182
+ else await sendStream(answer, transport);
377
1183
  } catch (error) {
378
1184
  try {
379
1185
  server.emitter.emit("error", error);
@@ -462,6 +1268,40 @@ function bindClient(client, transport) {
462
1268
  };
463
1269
  }
464
1270
  //#endregion
1271
+ //#region src/core/MCPMethodManager.ts
1272
+ /**
1273
+ * The modern method registry an {@link import('./types.js').MCPServerInterface}
1274
+ * dispatches through — a name-keyed store of {@link MCPMethodHandler}s that owns its
1275
+ * map rather than exposing one.
1276
+ *
1277
+ * @remarks
1278
+ * - **One seam.** The server registers its built-in modern methods here at construction
1279
+ * and resolves EVERY modern method from here, so a consumer's method and a built-in
1280
+ * are the same kind of thing on the same path.
1281
+ * - **Registration is a write, not a merge.** `add` under a name already present
1282
+ * REPLACES it, which is how a consumer overrides a built-in; there is no precedence
1283
+ * rule to remember.
1284
+ * - **A narrower contract than a `Map`.** Callers register and resolve; they cannot
1285
+ * iterate, clear, or otherwise reach the server's internal state through it.
1286
+ *
1287
+ * @example
1288
+ * ```ts
1289
+ * const methods = new MCPMethodManager()
1290
+ * methods.add('tools/list', async (request) => buildJSONRPCResult(request.id ?? null, { tools: [] }))
1291
+ * methods.method('tools/list') // the handler
1292
+ * methods.method('tools/nope') // undefined → the dispatch branch answers -32601
1293
+ * ```
1294
+ */
1295
+ var MCPMethodManager = class {
1296
+ #handlers = /* @__PURE__ */ new Map();
1297
+ add(name, handler) {
1298
+ this.#handlers.set(name, handler);
1299
+ }
1300
+ method(name) {
1301
+ return this.#handlers.get(name);
1302
+ }
1303
+ };
1304
+ //#endregion
465
1305
  //#region src/core/MCPServer.ts
466
1306
  /**
467
1307
  * A transport-agnostic Model Context Protocol server — dispatches JSON-RPC 2.0
@@ -474,14 +1314,15 @@ function bindClient(client, transport) {
474
1314
  * `JSON.parse`s the raw message (a failure → a `-32700` response), narrows it to
475
1315
  * a request (a non-request → a `-32600` response), dispatches, and serializes the
476
1316
  * response back to a string (`undefined` for a notification).
477
- * - **The method switch.** `initialize` negotiates the protocol version + advertises
478
- * the tools capability; `notifications/initialized` is a notification (no
479
- * response); `ping` returns `{}`; `tools/list` lists the registry's tools (its
480
- * `parameters` renamed to `inputSchema`); `tools/call` runs a tool by name (the
481
- * {@link ToolManagerInterface} isolates a tool throw into the result `error`, which
482
- * maps to an `isError: true` tool result — so the server adds NO try/catch). An
483
- * unknown method `-32601`; a `tools/call` with a missing / non-string `name`
484
- * `-32602`.
1317
+ * - **Dual-era dispatch.** A request carrying the reserved modern version key uses
1318
+ * modern metadata validation and the registered method seam. Every other request
1319
+ * uses the legacy `initialize` / `ping` / `tools/list` / `tools/call` switch. The
1320
+ * wire era is selected per request and never stored.
1321
+ * - **One modern seam.** `server/discover`, `tools/list`, `tools/call`, and
1322
+ * `subscriptions/listen` are
1323
+ * registered on `methods` at construction and resolved from it on every dispatch
1324
+ * the same path a later method or a consumer's own takes, with an unregistered
1325
+ * method still answering `-32601`.
485
1326
  * - **Provider-agnostic.** Imports only core siblings — JSON-RPC + the tool registry,
486
1327
  * no HTTP, no model. Wire fields are narrowed via the contracts guards (no `as`).
487
1328
  * - **Observable (§13).** The owned `emitter` fires `request` at the top of every
@@ -492,97 +1333,259 @@ function bindClient(client, transport) {
492
1333
  * ```ts
493
1334
  * const tools = createToolManager()
494
1335
  * tools.add(createTool({ name: 'add', execute: (a) => Number(a.x) + Number(a.y) }))
495
- * const server = new MCPServer({ name: 'demo', version: '1.0.0', tools })
1336
+ * const server = new MCPServer({ identity: { name: 'demo', version: '1.0.0' }, tools })
496
1337
  * await server.handle('{"jsonrpc":"2.0","method":"ping","id":1}') // '{"jsonrpc":"2.0","id":1,"result":{}}'
497
1338
  * ```
498
1339
  */
499
1340
  var MCPServer = class {
500
1341
  #emitter;
501
- #name;
502
- #version;
503
- #tools;
1342
+ #options;
1343
+ #methods;
1344
+ #limits;
1345
+ #subscriptions = 0;
504
1346
  constructor(options) {
505
1347
  this.#emitter = new Emitter({
506
1348
  ...options.on !== void 0 ? { on: options.on } : {},
507
1349
  ...options.error !== void 0 ? { error: options.error } : {}
508
1350
  });
509
- this.#name = options.name;
510
- this.#version = options.version;
511
- this.#tools = options.tools;
1351
+ this.#options = options;
1352
+ this.#limits = {
1353
+ message: sanitizeBudget(options.limit?.message, DEFAULT_MCP_LIMITS.message),
1354
+ metadata: sanitizeBudget(options.limit?.metadata, DEFAULT_MCP_LIMITS.metadata),
1355
+ keys: sanitizeBudget(options.limit?.keys, DEFAULT_MCP_LIMITS.keys),
1356
+ state: sanitizeBudget(options.limit?.state, DEFAULT_MCP_LIMITS.state),
1357
+ content: sanitizeBudget(options.limit?.content, DEFAULT_MCP_LIMITS.content),
1358
+ subscriptions: sanitizeBudget(options.limit?.subscriptions, DEFAULT_MCP_LIMITS.subscriptions),
1359
+ depth: sanitizeBudget(options.limit?.depth, DEFAULT_MCP_LIMITS.depth)
1360
+ };
1361
+ this.#methods = new MCPMethodManager();
1362
+ this.#register();
512
1363
  }
513
1364
  get emitter() {
514
1365
  return this.#emitter;
515
1366
  }
516
- get name() {
517
- return this.#name;
1367
+ get identity() {
1368
+ return this.#options.identity;
518
1369
  }
519
- get version() {
520
- return this.#version;
1370
+ get methods() {
1371
+ return this.#methods;
521
1372
  }
522
- async dispatch(request) {
1373
+ async dispatch(request, options = {}) {
523
1374
  const id = request.id ?? null;
524
- this.#emitter.emit("request", request.method, id);
1375
+ const modern = isModernRequest(request);
1376
+ const era = modern ? "modern" : "legacy";
1377
+ this.#emitter.emit("request", request.method, id, era);
525
1378
  if (request.id === void 0) return;
1379
+ const metadata = request.params?.["_meta"];
1380
+ if (metadata !== void 0 && !isBoundedJSON(metadata, {
1381
+ bytes: this.#limits.metadata,
1382
+ keys: this.#limits.keys,
1383
+ depth: this.#limits.depth
1384
+ })) return buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: `_meta` exceeds the configured limit or contains an unsafe value");
1385
+ return modern ? this.#modern(request, id, options) : this.#legacy(request, id);
1386
+ }
1387
+ async handle(message, options) {
1388
+ if (!isBoundedString(message, this.#limits.message)) return JSON.stringify(buildJSONRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"));
1389
+ let parsed;
1390
+ try {
1391
+ parsed = JSON.parse(message);
1392
+ } catch {
1393
+ return JSON.stringify(buildJSONRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"));
1394
+ }
1395
+ const decoded = parseJSONRPCMessage(parsed);
1396
+ if (decoded === void 0 || !("method" in decoded)) return JSON.stringify(buildJSONRPCError(null, JSONRPC_INVALID_REQUEST, "Invalid Request"));
1397
+ const answer = await this.dispatch(decoded, options);
1398
+ if (answer === void 0) return void 0;
1399
+ return Symbol.asyncIterator in answer ? serializeStream(answer) : JSON.stringify(answer);
1400
+ }
1401
+ async #legacy(request, id) {
526
1402
  switch (request.method) {
527
1403
  case "initialize": {
528
1404
  const requested = request.params?.["protocolVersion"];
529
- return jsonRPCResult(id, initializeResult(this.#name, this.#version, isString(requested) ? requested : void 0));
1405
+ return buildJSONRPCResult(id, buildInitializeResult(this.#options.identity.name, this.#options.identity.version, isString(requested) ? requested : void 0));
530
1406
  }
531
- case "ping": return jsonRPCResult(id, {});
532
- case "tools/list": return jsonRPCResult(id, { tools: buildToolDescriptors(this.#tools) });
533
- case "tools/call": return this.#call(request, id);
534
- default: return jsonRPCError(id, JSONRPC_METHOD_NOT_FOUND, `Method not found: ${request.method}`);
1407
+ case "ping": return buildJSONRPCResult(id, {});
1408
+ case "tools/list": return buildJSONRPCResult(id, { tools: buildToolDescriptors(this.#options.tools) });
1409
+ case "tools/call": {
1410
+ const result = await this.#runTool(request, id);
1411
+ return "jsonrpc" in result ? result : buildJSONRPCResult(id, result);
1412
+ }
1413
+ default: return buildJSONRPCError(id, JSONRPC_METHOD_NOT_FOUND, `Method not found: ${request.method}`);
535
1414
  }
536
1415
  }
537
- async handle(message) {
538
- let parsed;
1416
+ #register() {
1417
+ this.#methods.add("server/discover", (request) => this.#discover(request));
1418
+ this.#methods.add("tools/list", (request) => this.#list(request));
1419
+ this.#methods.add("tools/call", (request, options) => this.#call(request, options));
1420
+ this.#methods.add("subscriptions/listen", (request, options) => this.#subscribe(request, options));
1421
+ }
1422
+ async #modern(request, id, options) {
1423
+ const context = parseRequestContext(request);
1424
+ if (context === void 0) return buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: malformed modern request metadata");
1425
+ if (inferEra(context.version) === void 0) return buildJSONRPCError(id, MCP_UNSUPPORTED_VERSION, `Unsupported protocol version: ${context.version}`, {
1426
+ supported: SUPPORTED_PROTOCOL_VERSIONS,
1427
+ requested: context.version
1428
+ });
1429
+ const handler = this.#methods.method(request.method);
1430
+ if (handler === void 0) return buildJSONRPCError(id, JSONRPC_METHOD_NOT_FOUND, `Method not found: ${request.method}`);
1431
+ return handler(request, options);
1432
+ }
1433
+ async #discover(request) {
1434
+ return buildJSONRPCResult(request.id ?? null, buildDiscoverResult(this.#options));
1435
+ }
1436
+ async #list(request) {
1437
+ return buildJSONRPCResult(request.id ?? null, buildModernResult({ tools: buildToolDescriptors(this.#options.tools) }, this.#options.identity, this.#options.cache?.ttl ?? 6e4, this.#options.cache?.scope));
1438
+ }
1439
+ async #call(request, options = {}) {
1440
+ const id = request.id ?? null;
1441
+ const input = await this.#input(request, options);
1442
+ if (input !== void 0) return input;
1443
+ const result = await this.#runTool(request, id);
1444
+ return "jsonrpc" in result ? result : buildJSONRPCResult(id, buildModernResult(result, this.#options.identity));
1445
+ }
1446
+ async #input(request, options) {
1447
+ const configured = this.#options.input;
1448
+ if (configured === void 0) return void 0;
1449
+ const id = request.id;
1450
+ if (id === void 0) return void 0;
1451
+ const params = request.params;
1452
+ const name = params?.["name"];
1453
+ if (!isString(name)) return void 0;
1454
+ const rawArguments = params?.["arguments"];
1455
+ const args = isRecord(rawArguments) ? rawArguments : {};
1456
+ const requestState = params?.["requestState"];
1457
+ const inputResponses = params?.["inputResponses"];
1458
+ if (requestState === void 0 && inputResponses === void 0) {
1459
+ const elicitation = await configured.elicit({
1460
+ request,
1461
+ name,
1462
+ arguments: args
1463
+ }, options);
1464
+ if (elicitation === void 0) return void 0;
1465
+ const principal = await configured.principal(request);
1466
+ return this.#required(request, name, elicitation, principal);
1467
+ }
1468
+ if (!isBoundedString(requestState, this.#limits.state) || !isRecord(inputResponses)) return buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: `inputResponses` and `requestState` are required together");
1469
+ const state = parseMCPInputState(await verifyToken(requestState, configured.secret));
1470
+ const principal = await configured.principal(request);
1471
+ if (state === void 0 || state.principal !== principal || state.ttl !== configured.ttl || state.origin === id || state.name !== name || !Object.hasOwn(inputResponses, state.key)) return buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: request state could not be verified for this retry");
1472
+ const response = inputResponses[state.key];
1473
+ if (!isElicitResult(response)) return buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: the elicitation response is missing or malformed");
1474
+ const elicitation = await configured.elicit({
1475
+ request,
1476
+ name,
1477
+ arguments: args,
1478
+ response,
1479
+ ...state.state !== void 0 ? { state: state.state } : {}
1480
+ }, options);
1481
+ return elicitation === void 0 ? void 0 : this.#required(request, name, elicitation, principal);
1482
+ }
1483
+ async #required(request, name, elicitation, principal) {
1484
+ const id = request.id;
1485
+ if (id === void 0) return buildJSONRPCError(null, JSONRPC_INVALID_REQUEST, "Invalid Request");
1486
+ const context = parseRequestContext(request);
1487
+ if (context === void 0 || !isFormElicitationSupported(context.capabilities)) return buildJSONRPCError(id, MCP_MISSING_CAPABILITY, "Server requires the elicitation capability for this request", { requiredCapabilities: { elicitation: {} } });
1488
+ if (!isElicitRequestFormParams(elicitation.request) || principal.length === 0 || !Number.isFinite(this.#options.input?.ttl) || (this.#options.input?.ttl ?? 0) <= 0) return buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: elicitation policy returned an invalid form or signing context");
1489
+ const configured = this.#options.input;
1490
+ if (configured === void 0) return buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: input is not configured");
1491
+ const key = crypto.randomUUID();
1492
+ const protectedState = {
1493
+ principal,
1494
+ ttl: configured.ttl,
1495
+ origin: id,
1496
+ key,
1497
+ name,
1498
+ ...elicitation.state !== void 0 ? { state: elicitation.state } : {}
1499
+ };
1500
+ if (!isBoundedJSON(protectedState, {
1501
+ bytes: this.#limits.state,
1502
+ depth: this.#limits.depth
1503
+ })) return buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: request state exceeds the configured limit");
1504
+ const requestState = await signToken(JSON.stringify(protectedState), {
1505
+ secret: configured.secret,
1506
+ ttl: configured.ttl
1507
+ });
1508
+ if (!isBoundedString(requestState, this.#limits.state)) return buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: request state exceeds the configured limit");
1509
+ return buildJSONRPCResult(id, {
1510
+ resultType: "input_required",
1511
+ inputRequests: { [key]: {
1512
+ method: "elicitation/create",
1513
+ params: {
1514
+ ...elicitation.request,
1515
+ mode: "form"
1516
+ }
1517
+ } },
1518
+ requestState,
1519
+ _meta: { [MCP_META_SERVER]: this.#options.identity }
1520
+ });
1521
+ }
1522
+ async #subscribe(request, options) {
1523
+ const id = request.id;
1524
+ if (id === void 0) return buildJSONRPCError(null, JSONRPC_INVALID_REQUEST, "Invalid Request");
1525
+ const requested = request.params?.["notifications"];
1526
+ if (!isSubscriptionFilter(requested)) return buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: a valid `notifications` filter is required");
1527
+ return this.#subscription(requested, id, options);
1528
+ }
1529
+ async *#subscription(requested, id, options) {
1530
+ if (this.#subscriptions >= this.#limits.subscriptions) return buildJSONRPCError(id, JSONRPC_SERVER_ERROR, "Server limit reached: too many live subscriptions");
1531
+ this.#subscriptions += 1;
539
1532
  try {
540
- parsed = JSON.parse(message);
541
- } catch {
542
- return JSON.stringify(jsonRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"));
1533
+ const configured = this.#options.subscription;
1534
+ const notifications = buildSubscriptionFilter(requested, configured?.notifications ?? {});
1535
+ yield buildSubscriptionAcknowledgement(notifications, id);
1536
+ if (configured !== void 0) {
1537
+ const source = await configured.listen(notifications, options);
1538
+ for await (const notification of source) if (matchesSubscriptionNotification(notification, notifications)) yield stampSubscriptionNotification(notification, id);
1539
+ }
1540
+ return buildSubscriptionResult(id, this.#options.identity);
1541
+ } finally {
1542
+ this.#subscriptions -= 1;
543
1543
  }
544
- const decoded = parseJSONRPCMessage(parsed);
545
- if (decoded === void 0 || !("method" in decoded)) return JSON.stringify(jsonRPCError(null, JSONRPC_INVALID_REQUEST, "Invalid Request"));
546
- const response = await this.dispatch(decoded);
547
- return response === void 0 ? void 0 : JSON.stringify(response);
548
1544
  }
549
- async #call(request, id) {
1545
+ async #runTool(request, id) {
550
1546
  const params = request.params;
551
1547
  const name = params?.["name"];
552
- if (!isString(name)) return jsonRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: a string `name` is required");
1548
+ if (!isString(name)) return buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: a string `name` is required");
553
1549
  const rawArguments = params?.["arguments"];
554
1550
  const args = isRecord(rawArguments) ? rawArguments : {};
555
1551
  const callId = request.id === void 0 ? crypto.randomUUID() : String(request.id);
556
- return jsonRPCResult(id, buildToolResult(await this.#tools.execute({
1552
+ const result = await this.#options.tools.execute({
557
1553
  id: callId,
558
1554
  name,
559
1555
  arguments: args
560
- })));
1556
+ });
1557
+ if (!result.success && !isBoundedString(result.error, this.#limits.content)) return buildJSONRPCError(id, JSONRPC_SERVER_ERROR, "Server limit exceeded: tool content is too large");
1558
+ if (result.success && result.value !== void 0 && !isBoundedJSON(result.value, {
1559
+ bytes: this.#limits.content,
1560
+ depth: this.#limits.depth
1561
+ })) return buildJSONRPCError(id, JSONRPC_SERVER_ERROR, "Server limit exceeded: tool content is too large or unsafe");
1562
+ const built = attempt(() => buildCallResult(result));
1563
+ return built.success ? built.value : buildJSONRPCError(id, JSONRPC_SERVER_ERROR, "Server could not serialize tool content");
561
1564
  }
562
1565
  };
563
1566
  //#endregion
564
1567
  //#region src/core/MCPClient.ts
565
1568
  /**
566
1569
  * A transport-agnostic Model Context Protocol CLIENT — connects to a REMOTE MCP server
567
- * over an injected {@link ClientTransportInterface}, runs the `initialize` handshake,
568
- * and exposes the server's tools as local {@link ToolInterface}s an agent can run.
1570
+ * over an injected {@link ClientTransportInterface}, negotiates the modern or legacy
1571
+ * wire era, and exposes the server's tools as local {@link ToolInterface}s an agent can run.
569
1572
  *
570
1573
  * @remarks
571
1574
  * - **The mirror of `MCPServer`.** The server DISPATCHES requests over a tool registry;
572
- * this client ISSUES them over a transport. `connect` runs `initialize`, validates and
573
- * exposes the negotiated `protocol`, then sends `notifications/initialized`; `tools()`
574
- * lists the remote tools and wraps each as a
1575
+ * this client ISSUES them over a transport. `connect` probes `server/discover` unless
1576
+ * pinned legacy, falls back to `initialize` only for a legacy peer, and exposes the
1577
+ * negotiated `version`; `tools()` lists the remote tools and wraps each as a
575
1578
  * local {@link ToolInterface} whose `execute` calls back through `call`; `call` runs a
576
1579
  * remote `tools/call` and returns the tool's value (a remote `isError: true` throws
577
- * locally, so an agent's {@link import('@orkestrel/agent').ToolManagerInterface}
578
- * isolates it into a result `error` just like a local throw).
1580
+ * locally, so an agent's {@link import('@orkestrel/tool').ToolManagerInterface}
1581
+ * isolates it into a `success: false` result just like a local throw).
579
1582
  * - **Request↔response correlation.** Each request is tagged with a monotonic numeric
580
1583
  * `id` ({@link #nextId}); a single transport `message` subscription resolves / rejects
581
1584
  * the matching {@link #pending} entry by `id`. A message that is NOT a response to a
582
1585
  * pending request is a server NOTIFICATION — re-surfaced on the `notification` event.
583
- * - **Per-request deadline.** `#request` races `AbortSignal.timeout(this.#timeout)` (the
584
- * taverna idiom never a raw `setTimeout`): a server that never replies REJECTS the
585
- * pending request once the deadline fires, never hanging.
1586
+ * - **Per-request deadline.** Each `#request` receives its own deadline: ordinary calls use
1587
+ * `this.#timeout`, while an explicitly bounded discovery uses the shorter probe deadline.
1588
+ * `AbortSignal.timeout` (never a raw `setTimeout`) rejects only that pending request.
586
1589
  * - **Transport-agnostic.** Imports only core siblings (JSON-RPC + the tool vocabulary);
587
1590
  * the concrete transport is injected. Wire fields are narrowed via the contracts
588
1591
  * guards (no `as`).
@@ -592,7 +1595,7 @@ var MCPServer = class {
592
1595
  *
593
1596
  * @example
594
1597
  * ```ts
595
- * const client = new MCPClient({ transport, name: 'agent', version: '1.0.0' })
1598
+ * const client = new MCPClient({ transport, identity: { name: 'agent', version: '1.0.0' } })
596
1599
  * await client.connect()
597
1600
  * const tools = await client.tools()
598
1601
  * agent.context.tools.add(tools) // the remote tools are now the agent's
@@ -602,22 +1605,32 @@ var MCPServer = class {
602
1605
  var MCPClient = class {
603
1606
  #emitter;
604
1607
  #transport;
605
- #name;
606
- #version;
1608
+ #identity;
1609
+ #capabilities;
1610
+ #pin;
607
1611
  #timeout;
1612
+ #probe;
608
1613
  #pending = /* @__PURE__ */ new Map();
609
1614
  #nextId = 0;
610
1615
  #connected = false;
611
- #protocol = void 0;
1616
+ #version = void 0;
1617
+ #era = void 0;
1618
+ #offer;
612
1619
  constructor(options) {
613
1620
  this.#emitter = new Emitter({
614
1621
  ...options.on !== void 0 ? { on: options.on } : {},
615
1622
  ...options.error !== void 0 ? { error: options.error } : {}
616
1623
  });
617
1624
  this.#transport = options.transport;
618
- this.#name = options.name ?? "taverna";
619
- this.#version = options.version ?? "1.0.0";
1625
+ this.#identity = options.identity ?? {
1626
+ name: "taverna",
1627
+ version: "1.0.0"
1628
+ };
1629
+ this.#capabilities = options.capabilities ?? {};
1630
+ this.#pin = options.version;
1631
+ this.#offer = options.version ?? "2026-07-28";
620
1632
  this.#timeout = options.timeout ?? 3e4;
1633
+ this.#probe = options.timeout === void 0 ? void 0 : Math.min(options.timeout, 50);
621
1634
  this.#transport.emitter.on("message", (message) => this.#receive(message));
622
1635
  }
623
1636
  get emitter() {
@@ -626,8 +1639,8 @@ var MCPClient = class {
626
1639
  get connected() {
627
1640
  return this.#connected;
628
1641
  }
629
- get protocol() {
630
- return this.#protocol;
1642
+ get version() {
1643
+ return this.#version;
631
1644
  }
632
1645
  get transport() {
633
1646
  return this.#transport;
@@ -638,38 +1651,68 @@ var MCPClient = class {
638
1651
  async connect() {
639
1652
  if (this.#connected) return;
640
1653
  await this.#transport.start();
641
- const result = await this.#request("initialize", {
642
- protocolVersion: MCP_PROTOCOL_VERSION,
643
- capabilities: {},
644
- clientInfo: {
645
- name: this.#name,
646
- version: this.#version
1654
+ if (this.#era === "legacy" || this.#pin !== void 0 && inferEra(this.#pin) === "legacy") {
1655
+ await this.#initialize(this.#pin ?? "2025-11-25");
1656
+ return;
1657
+ }
1658
+ let discovery;
1659
+ try {
1660
+ try {
1661
+ discovery = await this.discover();
1662
+ } catch (error) {
1663
+ if (!isMCPError(error) || error.code !== -32022) throw error;
1664
+ if (this.#pin !== void 0) throw error;
1665
+ const supported = isRecord(error.context) ? error.context["supported"] : void 0;
1666
+ const retry = isArray(supported) ? inferVersion(supported.filter((version) => isString(version))) : void 0;
1667
+ if (retry === void 0) throw error;
1668
+ this.#offer = retry;
1669
+ discovery = await this.discover();
647
1670
  }
648
- });
649
- const protocol = isRecord(result) ? result["protocolVersion"] : void 0;
650
- if (!isString(protocol) || !SUPPORTED_PROTOCOL_VERSIONS.includes(protocol)) {
651
- await this.#transport.close();
652
- if (isString(protocol)) throw new Error(`MCP server negotiated unsupported protocol version '${protocol}'`);
653
- throw new Error("MCP server returned a non-string protocol version");
1671
+ } catch (error) {
1672
+ if (!(this.#pin !== "2026-07-28" && this.#era === void 0 && (!isMCPError(error) || error.code !== -32022))) throw error;
1673
+ await this.#initialize(MCP_PROTOCOL_VERSION);
1674
+ return;
654
1675
  }
655
- this.#protocol = protocol;
1676
+ const version = inferVersion(discovery.supportedVersions);
1677
+ if (version === void 0) throw new MCPError("MCP server supports no compatible protocol version", MCP_UNSUPPORTED_VERSION, { supported: discovery.supportedVersions });
1678
+ this.#version = version;
1679
+ this.#era = "modern";
656
1680
  this.#connected = true;
657
- await this.#transport.send({
658
- jsonrpc: "2.0",
659
- method: "notifications/initialized"
660
- });
661
1681
  this.#emitter.emit("connect");
662
1682
  }
1683
+ async discover() {
1684
+ const result = await this.#request("server/discover", void 0, this.#probe, this.#version ?? this.#offer);
1685
+ if (!isRecord(result)) throw new MCPError("MCP server returned a malformed discovery result", JSONRPC_INVALID_PARAMS, result);
1686
+ const advertised = result["supportedVersions"];
1687
+ const capabilities = result["capabilities"];
1688
+ const ttl = result["ttlMs"];
1689
+ const scope = result["cacheScope"];
1690
+ const instructions = result["instructions"];
1691
+ const metadata = result["_meta"];
1692
+ const resultType = result["resultType"];
1693
+ if (!isArray(advertised) || !isRecord(capabilities) || !isNumber(ttl) || scope !== "public" && scope !== "private" || resultType !== void 0 && resultType !== "complete" || instructions !== void 0 && !isString(instructions) || metadata !== void 0 && !isRecord(metadata)) throw new MCPError("MCP server returned a malformed discovery result", JSONRPC_INVALID_PARAMS, result);
1694
+ const supportedVersions = [];
1695
+ for (const version of advertised) if (isMCPVersion(version)) supportedVersions.push(version);
1696
+ return {
1697
+ supportedVersions,
1698
+ capabilities,
1699
+ resultType: resultType ?? "complete",
1700
+ ttlMs: ttl,
1701
+ cacheScope: scope,
1702
+ ...instructions === void 0 ? {} : { instructions },
1703
+ ...metadata === void 0 ? {} : { _meta: metadata }
1704
+ };
1705
+ }
663
1706
  async disconnect() {
664
1707
  if (!this.#connected) return;
665
1708
  this.#connected = false;
666
- this.#protocol = void 0;
1709
+ this.#version = void 0;
667
1710
  for (const id of this.#pending.keys()) this.#settle(id, /* @__PURE__ */ new Error("MCP client disconnected"), true);
668
1711
  await this.#transport.close();
669
1712
  this.#emitter.emit("disconnect");
670
1713
  }
671
1714
  async tools() {
672
- const result = await this.#request("tools/list");
1715
+ const result = await this.#request("tools/list", void 0, this.#timeout);
673
1716
  if (!isRecord(result) || !isArray(result["tools"])) return [];
674
1717
  const tools = [];
675
1718
  for (const descriptor of result["tools"]) {
@@ -683,7 +1726,7 @@ var MCPClient = class {
683
1726
  const result = await this.#request("tools/call", {
684
1727
  name,
685
1728
  arguments: args
686
- });
1729
+ }, this.#timeout);
687
1730
  const text = this.#text(result);
688
1731
  if (isRecord(result) && result["isError"] === true) throw new Error(text.length > 0 ? text : `MCP tool '${name}' failed`);
689
1732
  if (text.length === 0) return void 0;
@@ -693,24 +1736,46 @@ var MCPClient = class {
693
1736
  return text;
694
1737
  }
695
1738
  }
696
- #request(method, params) {
1739
+ #request(method, params, deadline, version) {
697
1740
  this.#nextId += 1;
698
1741
  const id = this.#nextId;
1742
+ const timeout = deadline;
1743
+ const modern = version ?? (this.#era === "modern" ? this.#version : void 0);
1744
+ const stamped = modern === void 0 ? params : {
1745
+ ...params ?? {},
1746
+ _meta: {
1747
+ [MCP_META_VERSION]: modern,
1748
+ [MCP_META_CAPABILITIES]: this.#capabilities,
1749
+ [MCP_META_CLIENT]: this.#identity
1750
+ }
1751
+ };
699
1752
  const request = {
700
1753
  jsonrpc: "2.0",
701
1754
  id,
702
1755
  method,
703
- ...params === void 0 ? {} : { params }
1756
+ ...stamped === void 0 ? {} : { params: stamped }
704
1757
  };
705
1758
  return new Promise((resolve, reject) => {
706
- const deadline = AbortSignal.timeout(this.#timeout);
707
- const timeout = this.#timeoutRequest.bind(this, id, method);
708
- deadline.addEventListener("abort", timeout, { once: true });
1759
+ if (timeout === void 0) {
1760
+ this.#pending.set(id, {
1761
+ resolve,
1762
+ reject,
1763
+ method
1764
+ });
1765
+ this.#transport.send(request).catch((error) => {
1766
+ this.#settle(id, error instanceof Error ? error : new Error(String(error)), true);
1767
+ });
1768
+ return;
1769
+ }
1770
+ const signal = AbortSignal.timeout(timeout);
1771
+ const abort = this.#timeoutRequest.bind(this, id, method, timeout);
1772
+ signal.addEventListener("abort", abort, { once: true });
709
1773
  this.#pending.set(id, {
710
1774
  resolve,
711
1775
  reject,
712
- deadline,
713
- timeout
1776
+ method,
1777
+ deadline: signal,
1778
+ timeout: abort
714
1779
  });
715
1780
  this.#transport.send(request).catch((error) => {
716
1781
  this.#settle(id, error instanceof Error ? error : new Error(String(error)), true);
@@ -719,9 +1784,15 @@ var MCPClient = class {
719
1784
  }
720
1785
  #receive(message) {
721
1786
  if (isJSONRPCResponse(message) && isRequestId(message.id)) {
722
- if (this.#pending.has(message.id)) {
1787
+ const pending = this.#pending.get(message.id);
1788
+ if (pending !== void 0) {
1789
+ if (pending.method === "server/discover" && (message.error === void 0 || message.error.code !== -32601 && message.error.code !== -32600)) this.#era = "modern";
723
1790
  if (message.error !== void 0) this.#settle(message.id, new MCPError(message.error.message, message.error.code, message.error.data), true);
724
- else this.#settle(message.id, message.result, false);
1791
+ else {
1792
+ const resultType = isRecord(message.result) ? message.result["resultType"] : void 0;
1793
+ if (isRecord(message.result) && Object.hasOwn(message.result, "resultType") && resultType !== "complete") this.#settle(message.id, new MCPError(`MCP result type '${String(resultType)}' is not supported`, JSONRPC_INVALID_PARAMS, { resultType }), true);
1794
+ else this.#settle(message.id, message.result, false);
1795
+ }
725
1796
  return;
726
1797
  }
727
1798
  }
@@ -744,14 +1815,42 @@ var MCPClient = class {
744
1815
  for (const block of result["content"]) if (isRecord(block) && isString(block["text"])) parts.push(block["text"]);
745
1816
  return parts.join("\n");
746
1817
  }
747
- #timeoutRequest(id, method) {
748
- this.#settle(id, /* @__PURE__ */ new Error(`MCP request '${method}' timed out after ${this.#timeout}ms`), true);
1818
+ async #initialize(version) {
1819
+ const result = await this.#request("initialize", {
1820
+ protocolVersion: version,
1821
+ capabilities: {},
1822
+ clientInfo: this.#identity
1823
+ }, this.#timeout);
1824
+ const protocol = isRecord(result) ? result["protocolVersion"] : void 0;
1825
+ if (protocol === void 0) {
1826
+ await this.#transport.close();
1827
+ throw new Error("MCP server returned no protocol version");
1828
+ }
1829
+ if (!isString(protocol)) {
1830
+ await this.#transport.close();
1831
+ throw new Error("MCP server returned a malformed protocol version");
1832
+ }
1833
+ if (!isMCPVersion(protocol) || inferEra(protocol) !== "legacy") {
1834
+ await this.#transport.close();
1835
+ throw new Error(`MCP server negotiated unsupported protocol version '${protocol}'`);
1836
+ }
1837
+ this.#version = protocol;
1838
+ this.#era = "legacy";
1839
+ this.#connected = true;
1840
+ await this.#transport.send({
1841
+ jsonrpc: "2.0",
1842
+ method: "notifications/initialized"
1843
+ });
1844
+ this.#emitter.emit("connect");
1845
+ }
1846
+ #timeoutRequest(id, method, timeout) {
1847
+ this.#settle(id, /* @__PURE__ */ new Error(`MCP request '${method}' timed out after ${timeout}ms`), true);
749
1848
  }
750
1849
  #settle(id, value, failed) {
751
1850
  const pending = this.#pending.get(id);
752
1851
  if (pending === void 0) return;
753
1852
  this.#pending.delete(id);
754
- pending.deadline.removeEventListener("abort", pending.timeout);
1853
+ if (pending.deadline !== void 0 && pending.timeout !== void 0) pending.deadline.removeEventListener("abort", pending.timeout);
755
1854
  if (failed) pending.reject(value);
756
1855
  else pending.resolve(value);
757
1856
  }
@@ -760,20 +1859,20 @@ var MCPClient = class {
760
1859
  //#region src/core/factories.ts
761
1860
  /**
762
1861
  * Create a transport-agnostic Model Context Protocol server — exposes a live
763
- * {@link import('@orkestrel/agent').ToolManagerInterface} over JSON-RPC 2.0
1862
+ * {@link import('@orkestrel/tool').ToolManagerInterface} over JSON-RPC 2.0
764
1863
  * (`initialize` / `ping` / `tools/list` / `tools/call`).
765
1864
  *
766
1865
  * @remarks
767
1866
  * Pump raw message strings through `handle` (parse → dispatch → serialize) from a
768
1867
  * transport, or call the typed `dispatch` directly with an already-parsed request.
769
1868
  * The server is provider-agnostic — JSON-RPC plus the tool registry, with no HTTP
770
- * and no model. The {@link import('@orkestrel/agent').ToolManagerInterface} already
771
- * isolates a thrown tool into a result error (surfaced as an MCP `isError: true`
772
- * tool result), so a misbehaving tool never crashes a dispatch. Subscribe to the
1869
+ * and no model. The {@link import('@orkestrel/tool').ToolManagerInterface} already
1870
+ * isolates a thrown tool into a `success: false` result (surfaced as an MCP
1871
+ * `isError: true` tool result), so a misbehaving tool never crashes a dispatch. Subscribe to the
773
1872
  * `request` event via `server.emitter.on('request', …)` for tracing.
774
1873
  *
775
- * @param options - `name` / `version` (the server identity), `tools` (the live
776
- * registry to expose), an optional `description`, and the reserved `on`
1874
+ * @param options - `identity` (the server identity), `tools` (the live
1875
+ * registry to expose), optional `instructions`, and the reserved `on`
777
1876
  * {@link import('@orkestrel/emitter').EmitterHooks} (see {@link MCPServerOptions})
778
1877
  * @returns A working {@link MCPServerInterface}
779
1878
  *
@@ -784,7 +1883,7 @@ var MCPClient = class {
784
1883
  * const tools = createToolManager()
785
1884
  * tools.add(createTool({ name: 'add', execute: (a) => Number(a.x) + Number(a.y) }))
786
1885
  *
787
- * const server = createMCPServer({ name: 'calculator', version: '1.0.0', tools })
1886
+ * const server = createMCPServer({ identity: { name: 'calculator', version: '1.0.0' }, tools })
788
1887
  * server.emitter.on('request', (method, id) => log(method, id))
789
1888
  *
790
1889
  * // A transport pumps message strings through `handle`:
@@ -799,7 +1898,7 @@ function createMCPServer(options) {
799
1898
  * Create a transport-agnostic Model Context Protocol CLIENT — connects to a REMOTE
800
1899
  * MCP server over an injected {@link import('./types.js').ClientTransportInterface},
801
1900
  * runs the `initialize` handshake, and exposes the server's tools as local
802
- * {@link import('@orkestrel/agent').ToolInterface}s an agent can run.
1901
+ * {@link import('@orkestrel/tool').ToolInterface}s an agent can run.
803
1902
  *
804
1903
  * @remarks
805
1904
  * The egress mirror of {@link createMCPServer}: where the server exposes a local tool
@@ -807,14 +1906,14 @@ function createMCPServer(options) {
807
1906
  * validates and exposes the negotiated protocol, `tools()` lists + wraps the remote
808
1907
  * tools (each `execute` calls back over the wire),
809
1908
  * and `call(name, args)` runs a remote `tools/call` (a remote tool failure throws
810
- * locally, so an agent's {@link import('@orkestrel/agent').ToolManagerInterface}
1909
+ * locally, so an agent's {@link import('@orkestrel/tool').ToolManagerInterface}
811
1910
  * isolates it). The transport is injected — a concrete one (the HTTP transport over
812
1911
  * `fetch`) lives in `@src/server`; the client itself is provider-agnostic. Subscribe
813
1912
  * to `connect` / `disconnect` / `notification` via `client.on(...)` (or
814
1913
  * `client.emitter.on(...)`).
815
1914
  *
816
- * @param options - `transport` (the carrier; REQUIRED), `name` / `version` (the client
817
- * identity), `timeout` (the per-request deadline), and the reserved `on`
1915
+ * @param options - `transport` (the carrier; REQUIRED), an optional `identity`
1916
+ * (the client identity), `timeout` (the per-request deadline), and the reserved `on`
818
1917
  * {@link import('@orkestrel/emitter').EmitterHooks} (see {@link MCPClientOptions})
819
1918
  * @returns A working {@link MCPClientInterface}
820
1919
  *
@@ -876,6 +1975,6 @@ function createDuplexClientTransport(transport) {
876
1975
  };
877
1976
  }
878
1977
  //#endregion
879
- export { DEFAULT_MCP_CLIENT_NAME, DEFAULT_MCP_CLIENT_VERSION, DEFAULT_MCP_REQUEST_TIMEOUT, JSONRPC_INVALID_PARAMS, JSONRPC_INVALID_REQUEST, JSONRPC_METHOD_NOT_FOUND, JSONRPC_PARSE_ERROR, JSONRPC_SERVER_ERROR, MCPClient, MCPError, MCPServer, MCP_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, bindClient, bindServer, buildToolDescriptors, buildToolResult, createDuplexClientTransport, createMCPClient, createMCPServer, initializeResult, isInitializeRequest, isJSONRPCMessage, isJSONRPCRequest, isJSONRPCResponse, isMCPError, isRequestId, jsonRPCError, jsonRPCResult, parseJSONRPCMessage };
1978
+ export { DEFAULT_MCP_CACHE_TTL, DEFAULT_MCP_CLIENT_NAME, DEFAULT_MCP_CLIENT_VERSION, DEFAULT_MCP_LIMITS, DEFAULT_MCP_PROBE_TIMEOUT, DEFAULT_MCP_REQUEST_TIMEOUT, JSONRPC_INVALID_PARAMS, JSONRPC_INVALID_REQUEST, JSONRPC_METHOD_NOT_FOUND, JSONRPC_PARSE_ERROR, JSONRPC_SERVER_ERROR, MCPClient, MCPError, MCPMethodManager, MCPServer, MCP_HEADER_MISMATCH, MCP_LEGACY_VERSION, MCP_META_CAPABILITIES, MCP_META_CLIENT, MCP_META_SERVER, MCP_META_SUBSCRIPTION, MCP_META_VERSION, MCP_MISSING_CAPABILITY, MCP_MODERN_VERSION, MCP_PROTOCOL_VERSION, MCP_UNSUPPORTED_VERSION, SUPPORTED_PROTOCOL_VERSIONS, bindClient, bindServer, buildCallResult, buildDiscoverResult, buildInitializeResult, buildJSONRPCError, buildJSONRPCResult, buildModernResult, buildSubscriptionAcknowledgement, buildSubscriptionFilter, buildSubscriptionResult, buildToolDescriptors, createDuplexClientTransport, createMCPClient, createMCPServer, inferEra, inferVersion, isBoundedJSON, isBoundedString, isElicitPrimitiveSchema, isElicitRequest, isElicitRequestFormParams, isElicitRequestURLParams, isElicitResult, isFormElicitationSupported, isInitializeRequest, isInputRequest, isInputRequests, isInputRequiredResult, isJSONRPCMessage, isJSONRPCRequest, isJSONRPCResponse, isMCPError, isMCPVersion, isModernRequest, isRequestId, isSubscriptionFilter, matchesSubscriptionNotification, parseJSONRPCMessage, parseMCPInputState, parseRequestContext, sendStream, serializeStream, stampSubscriptionNotification };
880
1979
 
881
1980
  //# sourceMappingURL=index.js.map