@orkestrel/mcp 0.0.4 → 0.0.6

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.
@@ -5,15 +5,16 @@ import { Tool } from "@orkestrel/agent";
5
5
  /** The MCP protocol revision this server implements (the default negotiated version). */
6
6
  var MCP_PROTOCOL_VERSION = "2025-06-18";
7
7
  /**
8
- * The MCP protocol revisions this server can negotiate — the current
9
- * {@link MCP_PROTOCOL_VERSION} plus a prior rev a client may still request.
8
+ * The MCP protocol revisions this server can negotiate.
10
9
  *
11
10
  * @remarks
12
11
  * `initialize` echoes the client's requested `protocolVersion` when it appears in
13
12
  * this list, else falls back to {@link MCP_PROTOCOL_VERSION}. Frozen so the list is
14
- * an immutable contract.
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.
15
16
  */
16
- var SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(["2025-06-18", "2025-03-26"]);
17
+ var SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(["2025-06-18"]);
17
18
  /** JSON-RPC 2.0 reserved error: invalid JSON was received (the message did not parse). */
18
19
  var JSONRPC_PARSE_ERROR = -32700;
19
20
  /** JSON-RPC 2.0 reserved error: the payload was not a valid Request object. */
@@ -34,6 +35,61 @@ var DEFAULT_MCP_CLIENT_VERSION = "1.0.0";
34
35
  */
35
36
  var DEFAULT_MCP_REQUEST_TIMEOUT = 3e4;
36
37
  //#endregion
38
+ //#region src/core/errors.ts
39
+ /**
40
+ * A remote Model Context Protocol JSON-RPC error, preserving its machine-readable
41
+ * numeric code and optional structured context.
42
+ *
43
+ * @remarks
44
+ * {@link MCPClient} throws this error only for a remote JSON-RPC `error` response.
45
+ * Local lifecycle and transport conditions such as disconnects and request timeouts
46
+ * remain plain `Error`s. `context` carries the response's optional `error.data`
47
+ * unchanged and is `undefined` when the peer omitted it.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * const error = new MCPError('Method not found', -32601, { method: 'missing' })
52
+ * error.code // -32601
53
+ * error.context // { method: 'missing' }
54
+ * ```
55
+ */
56
+ var MCPError = class extends Error {
57
+ name = "MCPError";
58
+ code;
59
+ context;
60
+ /**
61
+ * Create a remote MCP protocol error.
62
+ *
63
+ * @param message - The human-readable JSON-RPC error message
64
+ * @param code - The machine-readable numeric JSON-RPC error code
65
+ * @param context - The optional JSON-RPC `error.data` payload
66
+ */
67
+ constructor(message, code, context) {
68
+ super(message);
69
+ this.code = code;
70
+ this.context = context;
71
+ }
72
+ };
73
+ /**
74
+ * Determine whether an unknown value is an {@link MCPError}.
75
+ *
76
+ * @param value - The unknown value to inspect
77
+ * @returns `true` only when the value is an `MCPError`
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * isMCPError(new MCPError('Method not found', -32601)) // true
82
+ * isMCPError(new Error('Method not found')) // false
83
+ * ```
84
+ */
85
+ function isMCPError(value) {
86
+ try {
87
+ return value instanceof MCPError;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+ //#endregion
37
93
  //#region src/core/validators.ts
38
94
  /**
39
95
  * Determine whether a value is a valid JSON-RPC REQUEST `id` — a string, a number,
@@ -279,6 +335,132 @@ function initializeResult(name, version, requested) {
279
335
  }
280
336
  };
281
337
  }
338
+ /**
339
+ * Pipe an {@link MCPTransportInterface} into an {@link MCPServerInterface} — every
340
+ * inbound message runs through `server.handle`, and a defined reply is written back
341
+ * via `transport.send`.
342
+ *
343
+ * @remarks
344
+ * `server.handle` already turns a malformed message into a serialized `-32700` /
345
+ * `-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
347
+ * to `server.emitter`'s `error` event (never rethrown, never an unhandled rejection);
348
+ * a listener on that event that itself throws is swallowed (the end of the line —
349
+ * the caller's own bug, never this binder's). The returned unbind DETACHES this
350
+ * binder (further inbound messages and the transport's `closed` signal are ignored)
351
+ * WITHOUT closing the transport — closing is the caller's decision.
352
+ *
353
+ * `listen`/`closed` are REPLACE semantics (§ port contract): the returned unbind
354
+ * DETACHES by replacing this binder's own handlers with no-ops, so a subsequent
355
+ * `bindServer` call on the SAME transport is never double-dispatched by a stale
356
+ * subscription left behind — an unbind→rebind cycle yields exactly one reply per
357
+ * request.
358
+ *
359
+ * @param server - The transport-agnostic server to dispatch inbound messages over
360
+ * @param transport - The duplex channel to pipe the server over
361
+ * @returns Detach this binder from the transport (does not close it)
362
+ *
363
+ * @example
364
+ * ```ts
365
+ * const unbind = bindServer(server, transport)
366
+ * // ... later, detach without closing:
367
+ * unbind()
368
+ * ```
369
+ */
370
+ function bindServer(server, transport) {
371
+ let active = true;
372
+ transport.listen(async (message) => {
373
+ if (!active) return;
374
+ try {
375
+ const response = await server.handle(message);
376
+ if (response !== void 0) await transport.send(response);
377
+ } catch (error) {
378
+ try {
379
+ server.emitter.emit("error", error);
380
+ } catch {}
381
+ }
382
+ });
383
+ transport.closed(() => {
384
+ active = false;
385
+ });
386
+ return () => {
387
+ active = false;
388
+ transport.listen(() => {});
389
+ transport.closed(() => {});
390
+ };
391
+ }
392
+ /**
393
+ * Pipe an {@link MCPTransportInterface} into an {@link MCPClientInterface} — every
394
+ * inbound message is decoded and delivered onto the client's OWN transport
395
+ * (`client.transport.emitter`'s `message` / `close` events), resolving/rejecting the
396
+ * client's correlated pending requests exactly as a direct reply would.
397
+ *
398
+ * @remarks
399
+ * The client's outbound writes flow through `client.transport.send` — its existing,
400
+ * unmodified request/response correlation — so `client` must have been constructed
401
+ * with a {@link import('./types.js').ClientTransportInterface} that itself carries
402
+ * the SAME `transport` (see {@link import('./factories.js').createDuplexClientTransport},
403
+ * the additive factory that adapts an {@link MCPTransportInterface} into that shape);
404
+ * this binder then completes the inbound half by decoding each message and pushing it
405
+ * onto `client.transport.emitter` (an {@link import('@orkestrel/emitter').EmitterInterface}
406
+ * exposes `emit`, so no client modification is needed). A malformed / non-JSON-RPC
407
+ * inbound message is DROPPED (§14, total — never throws); a delivery fault is routed to
408
+ * `client.transport.emitter`'s `error` event (never rethrown). The returned unbind
409
+ * DETACHES this binder (further inbound messages and the transport's `closed` signal are
410
+ * ignored) WITHOUT closing the transport.
411
+ *
412
+ * `listen`/`closed` are REPLACE semantics (§ port contract): the returned unbind
413
+ * DETACHES by replacing this binder's own handlers with no-ops, so a subsequent
414
+ * `bindClient` call on the SAME transport is never double-dispatched by a stale
415
+ * subscription left behind — an unbind→rebind cycle delivers exactly one `message`
416
+ * emit per inbound reply.
417
+ *
418
+ * @param client - The transport-agnostic client whose transport to deliver messages onto
419
+ * @param transport - The duplex channel to pipe the client over
420
+ * @returns Detach this binder from the transport (does not close it)
421
+ *
422
+ * @example
423
+ * ```ts
424
+ * const client = createMCPClient({ transport: createDuplexClientTransport(transport) })
425
+ * const unbind = bindClient(client, transport)
426
+ * await client.connect()
427
+ * // ... later, detach without closing:
428
+ * unbind()
429
+ * ```
430
+ */
431
+ function bindClient(client, transport) {
432
+ let active = true;
433
+ transport.listen((message) => {
434
+ if (!active) return;
435
+ let parsed;
436
+ try {
437
+ parsed = JSON.parse(message);
438
+ } catch {
439
+ return;
440
+ }
441
+ const decoded = parseJSONRPCMessage(parsed);
442
+ if (decoded === void 0) return;
443
+ try {
444
+ client.transport.emitter.emit("message", decoded);
445
+ } catch (error) {
446
+ try {
447
+ client.transport.emitter.emit("error", error);
448
+ } catch {}
449
+ }
450
+ });
451
+ transport.closed(() => {
452
+ if (!active) return;
453
+ active = false;
454
+ try {
455
+ client.transport.emitter.emit("close");
456
+ } catch {}
457
+ });
458
+ return () => {
459
+ active = false;
460
+ transport.listen(() => {});
461
+ transport.closed(() => {});
462
+ };
463
+ }
282
464
  //#endregion
283
465
  //#region src/core/MCPServer.ts
284
466
  /**
@@ -321,8 +503,8 @@ var MCPServer = class {
321
503
  #tools;
322
504
  constructor(options) {
323
505
  this.#emitter = new Emitter({
324
- on: options.on,
325
- error: options.error
506
+ ...options.on !== void 0 ? { on: options.on } : {},
507
+ ...options.error !== void 0 ? { error: options.error } : {}
326
508
  });
327
509
  this.#name = options.name;
328
510
  this.#version = options.version;
@@ -387,8 +569,9 @@ var MCPServer = class {
387
569
  *
388
570
  * @remarks
389
571
  * - **The mirror of `MCPServer`.** The server DISPATCHES requests over a tool registry;
390
- * this client ISSUES them over a transport. `connect` runs `initialize` then sends
391
- * `notifications/initialized`; `tools()` lists the remote tools and wraps each as a
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
392
575
  * local {@link ToolInterface} whose `execute` calls back through `call`; `call` runs a
393
576
  * remote `tools/call` and returns the tool's value (a remote `isError: true` throws
394
577
  * locally, so an agent's {@link import('@orkestrel/agent').ToolManagerInterface}
@@ -425,10 +608,11 @@ var MCPClient = class {
425
608
  #pending = /* @__PURE__ */ new Map();
426
609
  #nextId = 0;
427
610
  #connected = false;
611
+ #protocol = void 0;
428
612
  constructor(options) {
429
613
  this.#emitter = new Emitter({
430
- on: options.on,
431
- error: options.error
614
+ ...options.on !== void 0 ? { on: options.on } : {},
615
+ ...options.error !== void 0 ? { error: options.error } : {}
432
616
  });
433
617
  this.#transport = options.transport;
434
618
  this.#name = options.name ?? "taverna";
@@ -442,6 +626,9 @@ var MCPClient = class {
442
626
  get connected() {
443
627
  return this.#connected;
444
628
  }
629
+ get protocol() {
630
+ return this.#protocol;
631
+ }
445
632
  get transport() {
446
633
  return this.#transport;
447
634
  }
@@ -451,7 +638,7 @@ var MCPClient = class {
451
638
  async connect() {
452
639
  if (this.#connected) return;
453
640
  await this.#transport.start();
454
- await this.#request("initialize", {
641
+ const result = await this.#request("initialize", {
455
642
  protocolVersion: MCP_PROTOCOL_VERSION,
456
643
  capabilities: {},
457
644
  clientInfo: {
@@ -459,6 +646,13 @@ var MCPClient = class {
459
646
  version: this.#version
460
647
  }
461
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");
654
+ }
655
+ this.#protocol = protocol;
462
656
  this.#connected = true;
463
657
  await this.#transport.send({
464
658
  jsonrpc: "2.0",
@@ -469,8 +663,8 @@ var MCPClient = class {
469
663
  async disconnect() {
470
664
  if (!this.#connected) return;
471
665
  this.#connected = false;
472
- for (const pending of this.#pending.values()) pending.reject(/* @__PURE__ */ new Error("MCP client disconnected"));
473
- this.#pending.clear();
666
+ this.#protocol = void 0;
667
+ for (const id of this.#pending.keys()) this.#settle(id, /* @__PURE__ */ new Error("MCP client disconnected"), true);
474
668
  await this.#transport.close();
475
669
  this.#emitter.emit("disconnect");
476
670
  }
@@ -510,38 +704,24 @@ var MCPClient = class {
510
704
  };
511
705
  return new Promise((resolve, reject) => {
512
706
  const deadline = AbortSignal.timeout(this.#timeout);
513
- const settle = () => {
514
- this.#pending.delete(id);
515
- deadline.removeEventListener("abort", onDeadline);
516
- };
517
- const onDeadline = () => {
518
- settle();
519
- reject(/* @__PURE__ */ new Error(`MCP request '${method}' timed out after ${this.#timeout}ms`));
520
- };
521
- deadline.addEventListener("abort", onDeadline, { once: true });
707
+ const timeout = this.#timeoutRequest.bind(this, id, method);
708
+ deadline.addEventListener("abort", timeout, { once: true });
522
709
  this.#pending.set(id, {
523
- resolve: (value) => {
524
- settle();
525
- resolve(value);
526
- },
527
- reject: (error) => {
528
- settle();
529
- reject(error);
530
- }
710
+ resolve,
711
+ reject,
712
+ deadline,
713
+ timeout
531
714
  });
532
715
  this.#transport.send(request).catch((error) => {
533
- const pending = this.#pending.get(id);
534
- if (pending === void 0) return;
535
- pending.reject(error instanceof Error ? error : new Error(String(error)));
716
+ this.#settle(id, error instanceof Error ? error : new Error(String(error)), true);
536
717
  });
537
718
  });
538
719
  }
539
720
  #receive(message) {
540
721
  if (isJSONRPCResponse(message) && isRequestId(message.id)) {
541
- const pending = this.#pending.get(message.id);
542
- if (pending !== void 0) {
543
- if (message.error !== void 0) pending.reject(/* @__PURE__ */ new Error(`MCP error ${message.error.code}: ${message.error.message}`));
544
- else pending.resolve(message.result);
722
+ if (this.#pending.has(message.id)) {
723
+ 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);
545
725
  return;
546
726
  }
547
727
  }
@@ -552,7 +732,7 @@ var MCPClient = class {
552
732
  const description = descriptor["description"];
553
733
  const options = {
554
734
  name,
555
- execute: (args) => this.call(name, args)
735
+ execute: this.call.bind(this, name)
556
736
  };
557
737
  if (isString(description)) options.description = description;
558
738
  if (isRecord(inputSchema)) options.parameters = inputSchema;
@@ -564,6 +744,17 @@ var MCPClient = class {
564
744
  for (const block of result["content"]) if (isRecord(block) && isString(block["text"])) parts.push(block["text"]);
565
745
  return parts.join("\n");
566
746
  }
747
+ #timeoutRequest(id, method) {
748
+ this.#settle(id, /* @__PURE__ */ new Error(`MCP request '${method}' timed out after ${this.#timeout}ms`), true);
749
+ }
750
+ #settle(id, value, failed) {
751
+ const pending = this.#pending.get(id);
752
+ if (pending === void 0) return;
753
+ this.#pending.delete(id);
754
+ pending.deadline.removeEventListener("abort", pending.timeout);
755
+ if (failed) pending.reject(value);
756
+ else pending.resolve(value);
757
+ }
567
758
  };
568
759
  //#endregion
569
760
  //#region src/core/factories.ts
@@ -613,7 +804,8 @@ function createMCPServer(options) {
613
804
  * @remarks
614
805
  * The egress mirror of {@link createMCPServer}: where the server exposes a local tool
615
806
  * registry over MCP, the client USES a remote server's tools. `connect()` handshakes,
616
- * `tools()` lists + wraps the remote tools (each `execute` calls back over the wire),
807
+ * validates and exposes the negotiated protocol, `tools()` lists + wraps the remote
808
+ * tools (each `execute` calls back over the wire),
617
809
  * and `call(name, args)` runs a remote `tools/call` (a remote tool failure throws
618
810
  * locally, so an agent's {@link import('@orkestrel/agent').ToolManagerInterface}
619
811
  * isolates it). The transport is injected — a concrete one (the HTTP transport over
@@ -642,7 +834,48 @@ function createMCPServer(options) {
642
834
  function createMCPClient(options) {
643
835
  return new MCPClient(options);
644
836
  }
837
+ /**
838
+ * Adapt an {@link MCPTransportInterface} (the environment-agnostic duplex message
839
+ * channel) into a {@link ClientTransportInterface} — the additive bridge that lets
840
+ * `createMCPClient` run over the new port without any change to `MCPClient`'s
841
+ * existing shape.
842
+ *
843
+ * @remarks
844
+ * Hand the RESULT to `createMCPClient({ transport })`, then pass the SAME
845
+ * `transport` to {@link import('./helpers.js').bindClient} to complete the inbound
846
+ * wiring: `send` serializes each outbound {@link JSONRPCMessage} and writes it via
847
+ * `transport.send`; `close` closes the underlying
848
+ * `transport`; `start` is a no-op (the duplex channel is already open by the time
849
+ * it is handed in — there is no separate connect step at this layer); `session` is
850
+ * always `undefined` (session correlation is a higher-level concern the duplex port
851
+ * does not carry). Inbound delivery (`emitter`'s `message` / `close` events) is
852
+ * `bindClient`'s job, not this factory's — the returned object exposes a `message`-
853
+ * capable emitter for `bindClient` to push onto.
854
+ *
855
+ * @param transport - The duplex channel to adapt
856
+ * @returns A {@link ClientTransportInterface} `createMCPClient` can drive
857
+ *
858
+ * @example
859
+ * ```ts
860
+ * const client = createMCPClient({ transport: createDuplexClientTransport(transport) })
861
+ * const unbind = bindClient(client, transport)
862
+ * await client.connect()
863
+ * ```
864
+ */
865
+ function createDuplexClientTransport(transport) {
866
+ return {
867
+ emitter: new Emitter(),
868
+ session: void 0,
869
+ async start() {},
870
+ async send(message) {
871
+ await transport.send(JSON.stringify(message));
872
+ },
873
+ async close() {
874
+ await transport.close();
875
+ }
876
+ };
877
+ }
645
878
  //#endregion
646
- 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, MCPServer, MCP_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, buildToolDescriptors, buildToolResult, createMCPClient, createMCPServer, initializeResult, isInitializeRequest, isJSONRPCMessage, isJSONRPCRequest, isJSONRPCResponse, isRequestId, jsonRPCError, jsonRPCResult, parseJSONRPCMessage };
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 };
647
880
 
648
881
  //# sourceMappingURL=index.js.map