@agentionai/agents 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,9 +32,50 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MCPClient = void 0;
39
+ exports.MCPClient = exports.MCPClientEvent = void 0;
40
+ const events_1 = __importDefault(require("events"));
37
41
  const Tool_1 = require("../tools/Tool");
42
+ const content_1 = require("./content");
43
+ const errors_1 = require("./errors");
44
+ /**
45
+ * Names of the events an {@link MCPClient} emits.
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * mcp.on(MCPClientEvent.DISCONNECTED, ({ willReconnect }) => {
50
+ * if (!willReconnect) scheduleManualReconnect();
51
+ * });
52
+ * ```
53
+ */
54
+ class MCPClientEvent {
55
+ }
56
+ exports.MCPClientEvent = MCPClientEvent;
57
+ /** Emitted after a successful {@link MCPClient.connect}. */
58
+ MCPClientEvent.CONNECTED = "connected";
59
+ /** Emitted whenever the connection is lost, deliberately or not. */
60
+ MCPClientEvent.DISCONNECTED = "disconnected";
61
+ /** Emitted before each automatic reconnect attempt. */
62
+ MCPClientEvent.RECONNECTING = "reconnecting";
63
+ /** Emitted when an automatic reconnect succeeds. */
64
+ MCPClientEvent.RECONNECTED = "reconnected";
65
+ /** Emitted when the server's tool list changes. */
66
+ MCPClientEvent.TOOLS_CHANGED = "toolsChanged";
67
+ /**
68
+ * Emitted for out-of-band transport errors and for failures inside background
69
+ * work such as reconnection or tool refresh.
70
+ */
71
+ MCPClientEvent.ERROR = "error";
72
+ const DEFAULT_RECONNECT = {
73
+ enabled: false,
74
+ maxRetries: 5,
75
+ initialDelayMs: 500,
76
+ maxDelayMs: 30000,
77
+ backoffFactor: 2,
78
+ };
38
79
  /**
39
80
  * MCPClient connects to an MCP (Model Context Protocol) server and converts its
40
81
  * tools into agention-lib {@link Tool} instances that can be passed to any agent.
@@ -43,6 +84,9 @@ const Tool_1 = require("../tools/Tool");
43
84
  * - **stdio** — spawns a local process and communicates over stdin/stdout
44
85
  * - **http** — connects to a remote MCP server over Streamable HTTP
45
86
  *
87
+ * The client is an `EventEmitter`; see {@link MCPClientEvent} for the lifecycle
88
+ * events a host can watch.
89
+ *
46
90
  * @requires @modelcontextprotocol/sdk - Install as a peer dependency:
47
91
  * ```
48
92
  * npm install @modelcontextprotocol/sdk
@@ -81,6 +125,20 @@ const Tool_1 = require("../tools/Tool");
81
125
  * agent.addTools(mcp.getTools());
82
126
  * ```
83
127
  *
128
+ * @example Cancellable, time-boxed tool calls
129
+ * ```typescript
130
+ * let turn = new AbortController();
131
+ *
132
+ * const mcp = MCPClient.fromUrl("https://my-mcp-server.com/mcp", {
133
+ * // Resolved per call, so each agent turn gets the current signal
134
+ * callOptions: () => ({ signal: turn.signal, timeout: 20_000 }),
135
+ * reconnect: { enabled: true },
136
+ * });
137
+ *
138
+ * // Interrupting the turn now aborts any in-flight MCP call
139
+ * turn.abort();
140
+ * ```
141
+ *
84
142
  * @example HTTP with OAuth
85
143
  * ```typescript
86
144
  * import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
@@ -90,25 +148,37 @@ const Tool_1 = require("../tools/Tool");
90
148
  * });
91
149
  * ```
92
150
  */
93
- class MCPClient {
151
+ class MCPClient extends events_1.default {
94
152
  constructor(transportConfig, options = {}) {
153
+ super();
95
154
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
96
155
  this.sdkClient = null;
97
156
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
98
157
  this.sdkTransport = null;
99
158
  this._tools = [];
100
- this.connected = false;
159
+ /** Signature per tool name, used to keep Tool identity stable across refreshes. */
160
+ this.toolSignatures = new Map();
161
+ this._state = "disconnected";
162
+ this.connectPromise = null;
163
+ this.reconnectPromise = null;
164
+ this.reconnectAttempts = 0;
165
+ this.reconnectTimer = null;
166
+ this.reconnectWake = null;
167
+ this.closedByUser = false;
101
168
  this.transportConfig = transportConfig;
102
- this.options = {
103
- clientName: options.clientName ?? "agention-mcp-client",
104
- clientVersion: options.clientVersion ?? "1.0.0",
105
- };
169
+ this.clientName = options.clientName ?? "agention-mcp-client";
170
+ this.clientVersion = options.clientVersion ?? "1.0.0";
171
+ this.callOptions = options.callOptions;
172
+ this.throwOnToolError = options.throwOnToolError ?? true;
173
+ this.refreshToolsOnListChanged = options.refreshToolsOnListChanged ?? true;
174
+ this.formatResult = options.formatResult;
175
+ this.reconnectOptions = { ...DEFAULT_RECONNECT, ...options.reconnect };
106
176
  }
107
177
  /**
108
178
  * Create an MCPClient that connects to a local MCP server process via stdio.
109
179
  *
110
180
  * @param config - Command and arguments to spawn the MCP server process
111
- * @param options - Optional client identification options
181
+ * @param options - Optional client identification and behaviour options
112
182
  */
113
183
  static fromStdio(config, options) {
114
184
  return new MCPClient({ type: "stdio", config }, options);
@@ -130,14 +200,158 @@ class MCPClient {
130
200
  /**
131
201
  * Connect to the MCP server and discover all available tools.
132
202
  *
133
- * This method is idempotent — calling it when already connected is a no-op.
203
+ * This method is idempotent — calling it when already connected is a no-op, and
204
+ * concurrent calls share a single connection attempt.
134
205
  * Must be called before {@link getTools}.
135
206
  *
136
207
  * @throws If the MCP server cannot be reached or the SDK is not installed
137
208
  */
138
209
  async connect() {
139
- if (this.connected)
210
+ if (this._state === "connected")
140
211
  return;
212
+ if (this.connectPromise)
213
+ return this.connectPromise;
214
+ this.closedByUser = false;
215
+ this._state = "connecting";
216
+ this.connectPromise = this.establish()
217
+ .then(() => {
218
+ this._state = "connected";
219
+ this.reconnectAttempts = 0;
220
+ this.emitEvent(MCPClientEvent.CONNECTED, { tools: this._tools });
221
+ })
222
+ .catch((error) => {
223
+ this._state = "disconnected";
224
+ throw error;
225
+ })
226
+ .finally(() => {
227
+ this.connectPromise = null;
228
+ });
229
+ return this.connectPromise;
230
+ }
231
+ /**
232
+ * Return all tools discovered from the MCP server as agention-lib Tool instances.
233
+ *
234
+ * Returns an empty array if {@link connect} has not been called yet.
235
+ * The returned tools can be passed directly to any agent via the `tools` config
236
+ * option or {@link BaseAgent.addTools}.
237
+ *
238
+ * Tool instances are stable across reconnects and tool-list refreshes: a tool
239
+ * whose definition is unchanged keeps its identity, so agents already holding it
240
+ * keep working. Listen for {@link MCPClientEvent.TOOLS_CHANGED} to learn when the
241
+ * list itself changed.
242
+ */
243
+ getTools() {
244
+ return this._tools;
245
+ }
246
+ /**
247
+ * Current connection state.
248
+ *
249
+ * @see {@link MCPConnectionState}
250
+ */
251
+ getState() {
252
+ return this._state;
253
+ }
254
+ /** Whether the client currently has a usable connection. */
255
+ isConnected() {
256
+ return this._state === "connected";
257
+ }
258
+ /**
259
+ * Replace the default options applied to every tool call.
260
+ *
261
+ * Pass a function to have them resolved per call — the usual way to scope an
262
+ * `AbortSignal` to the current agent turn.
263
+ *
264
+ * @example
265
+ * ```typescript
266
+ * mcp.setCallOptions(() => ({ signal: currentTurn.signal, timeout: 15_000 }));
267
+ * ```
268
+ */
269
+ setCallOptions(options) {
270
+ this.callOptions = options;
271
+ }
272
+ /**
273
+ * Call an MCP tool directly and receive the raw `CallToolResult`, including any
274
+ * image, audio or resource blocks and the `isError` flag.
275
+ *
276
+ * Unlike the wrapped {@link Tool} instances from {@link getTools}, this does not
277
+ * render the result or throw on `isError` — it is the escape hatch for hosts
278
+ * that want to handle MCP content themselves.
279
+ *
280
+ * @param name - Name of the tool as advertised by the server
281
+ * @param input - Arguments for the tool
282
+ * @param options - Per-call options, merged over the client defaults
283
+ */
284
+ async callTool(name, input = {}, options) {
285
+ return this.invokeTool(name, input, options);
286
+ }
287
+ /**
288
+ * Re-run tool discovery against the connected server.
289
+ *
290
+ * Called automatically when the server sends `notifications/tools/list_changed`
291
+ * (unless `refreshToolsOnListChanged` is disabled) and after a successful
292
+ * reconnect. Emits {@link MCPClientEvent.TOOLS_CHANGED} when the list differs.
293
+ *
294
+ * @returns The refreshed tool list
295
+ */
296
+ async refreshTools() {
297
+ const sdkClient = this.sdkClient;
298
+ if (!sdkClient) {
299
+ throw new errors_1.MCPNotConnectedError("MCPClient: Cannot refresh tools — client is not connected", "", this._state);
300
+ }
301
+ const definitions = await this.listAllTools(sdkClient);
302
+ this.applyToolDefinitions(definitions);
303
+ return this._tools;
304
+ }
305
+ /**
306
+ * Disconnect from the MCP server and release all resources.
307
+ *
308
+ * This method is idempotent — calling it when not connected is a no-op. It also
309
+ * cancels any pending automatic reconnect. After disconnecting, {@link getTools}
310
+ * returns an empty array; call {@link connect} again to start over.
311
+ */
312
+ async disconnect() {
313
+ const wasActive = this._state !== "disconnected" || this.sdkClient !== null;
314
+ this.closedByUser = true;
315
+ this.cancelReconnectWait();
316
+ if (this.reconnectPromise) {
317
+ await this.reconnectPromise.catch(() => undefined);
318
+ }
319
+ try {
320
+ if (this.transportConfig.type === "http" && this.sdkTransport) {
321
+ await this.sdkTransport.terminateSession?.();
322
+ }
323
+ if (this.sdkClient) {
324
+ await this.sdkClient.close();
325
+ }
326
+ }
327
+ finally {
328
+ this.sdkClient = null;
329
+ this.sdkTransport = null;
330
+ this._tools = [];
331
+ this.toolSignatures.clear();
332
+ this.reconnectAttempts = 0;
333
+ this._state = "disconnected";
334
+ }
335
+ if (wasActive) {
336
+ this.emitEvent(MCPClientEvent.DISCONNECTED, {
337
+ deliberate: true,
338
+ willReconnect: false,
339
+ });
340
+ }
341
+ }
342
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
343
+ on(event, listener) {
344
+ return super.on(event, listener);
345
+ }
346
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
347
+ once(event, listener) {
348
+ return super.once(event, listener);
349
+ }
350
+ /**
351
+ * Open the SDK client and transport, wire up lifecycle handlers and discover
352
+ * tools. Shared by {@link connect} and the reconnect loop.
353
+ */
354
+ async establish() {
141
355
  // Build module paths at runtime so tsc does not attempt to resolve them at
142
356
  // compile time. @modelcontextprotocol/sdk is an optional peer dependency and
143
357
  // may not be installed on the build host.
@@ -145,7 +359,7 @@ class MCPClient {
145
359
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
360
  const { Client } = await Promise.resolve(`${`${pkg}/client/index.js`}`).then(s => __importStar(require(s)));
147
361
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
148
- const sdkClient = new Client({ name: this.options.clientName, version: this.options.clientVersion }, { capabilities: {} });
362
+ const sdkClient = new Client({ name: this.clientName, version: this.clientVersion }, { capabilities: {} });
149
363
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
150
364
  let transport;
151
365
  if (this.transportConfig.type === "stdio") {
@@ -166,53 +380,249 @@ class MCPClient {
166
380
  ...(authProvider ? { authProvider } : {}),
167
381
  });
168
382
  }
383
+ // Registered before connect() so a notification arriving immediately after the
384
+ // handshake is not missed.
385
+ await this.subscribeToToolListChanges(sdkClient, pkg);
386
+ sdkClient.onclose = () => this.handleClose(sdkClient);
387
+ sdkClient.onerror = (error) => this.handleTransportError(sdkClient, error);
169
388
  await sdkClient.connect(transport);
170
389
  this.sdkClient = sdkClient;
171
390
  this.sdkTransport = transport;
172
- this.connected = true;
173
- // Paginate through all tools
174
- const allMcpTools = [];
391
+ const definitions = await this.listAllTools(sdkClient);
392
+ this.applyToolDefinitions(definitions, { silent: this._tools.length === 0 });
393
+ }
394
+ /**
395
+ * Ask the server to notify us about tool list changes. Failures here are not
396
+ * fatal — an older SDK or a server without the capability simply means the
397
+ * cached list stays as it was at connect time.
398
+ */
399
+ async subscribeToToolListChanges(
400
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
401
+ sdkClient, pkg) {
402
+ if (!this.refreshToolsOnListChanged)
403
+ return;
404
+ try {
405
+ const { ToolListChangedNotificationSchema } = (await Promise.resolve(`${
406
+ /* @vite-ignore */ `${pkg}/types.js`}`).then(s => __importStar(require(s))));
407
+ sdkClient.setNotificationHandler(ToolListChangedNotificationSchema, () => {
408
+ void this.refreshTools().catch((error) => this.emitError(error));
409
+ });
410
+ }
411
+ catch (error) {
412
+ this.emitError(error);
413
+ }
414
+ }
415
+ /** Page through `tools/list` until the server stops handing back a cursor. */
416
+ async listAllTools(
417
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
418
+ sdkClient) {
419
+ const definitions = [];
175
420
  let cursor;
176
421
  do {
177
422
  const response = await sdkClient.listTools(cursor ? { cursor } : {});
178
- allMcpTools.push(...response.tools);
423
+ definitions.push(...response.tools);
179
424
  cursor = response.nextCursor;
180
425
  } while (cursor);
181
- this._tools = allMcpTools.map((mcpTool) => this.wrapMcpTool(mcpTool));
426
+ return definitions;
182
427
  }
183
428
  /**
184
- * Return all tools discovered from the MCP server as agention-lib Tool instances.
185
- *
186
- * Returns an empty array if {@link connect} has not been called yet.
187
- * The returned tools can be passed directly to any agent via the `tools` config
188
- * option or {@link BaseAgent.addTools}.
429
+ * Reconcile the cached tool list with a fresh set of definitions, reusing the
430
+ * existing {@link Tool} instance for every definition that has not changed so
431
+ * agents holding a reference keep working.
189
432
  */
190
- getTools() {
191
- return this._tools;
433
+ applyToolDefinitions(definitions, { silent = false } = {}) {
434
+ const previous = new Map(this._tools.map((tool) => [tool.name, tool]));
435
+ const previousSignatures = this.toolSignatures;
436
+ const tools = [];
437
+ const signatures = new Map();
438
+ const added = [];
439
+ let changed = false;
440
+ for (const definition of definitions) {
441
+ const signature = JSON.stringify({
442
+ description: definition.description ?? null,
443
+ inputSchema: definition.inputSchema ?? null,
444
+ });
445
+ const existing = previous.get(definition.name);
446
+ if (existing && previousSignatures.get(definition.name) === signature) {
447
+ tools.push(existing);
448
+ }
449
+ else {
450
+ tools.push(this.wrapMcpTool(definition));
451
+ changed = true;
452
+ if (!existing)
453
+ added.push(definition.name);
454
+ }
455
+ signatures.set(definition.name, signature);
456
+ previous.delete(definition.name);
457
+ }
458
+ const removed = [...previous.keys()];
459
+ this._tools = tools;
460
+ this.toolSignatures = signatures;
461
+ if (!silent && (changed || removed.length > 0)) {
462
+ this.emitEvent(MCPClientEvent.TOOLS_CHANGED, { tools, added, removed });
463
+ }
192
464
  }
193
465
  /**
194
- * Disconnect from the MCP server and release all resources.
195
- *
196
- * This method is idempotent — calling it when not connected is a no-op.
197
- * After disconnecting, {@link getTools} returns an empty array.
466
+ * Handle the transport closing. Deliberate closes are reported by
467
+ * {@link disconnect} itself; everything else is an unexpected drop.
198
468
  */
199
- async disconnect() {
200
- if (!this.connected)
469
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
470
+ handleClose(sdkClient) {
471
+ if (this.closedByUser)
201
472
  return;
202
- try {
203
- if (this.transportConfig.type === "http" && this.sdkTransport) {
204
- await this.sdkTransport.terminateSession?.();
205
- }
206
- if (this.sdkClient) {
207
- await this.sdkClient.close();
473
+ // A close from a client we have already replaced or abandoned is stale.
474
+ if (this.sdkClient !== sdkClient)
475
+ return;
476
+ this.sdkClient = null;
477
+ this.sdkTransport = null;
478
+ const willReconnect = this.reconnectOptions.enabled && this.reconnectAttempts < this.reconnectOptions.maxRetries;
479
+ // A transport that fails reports the cause through onerror just before it
480
+ // closes, so the most recent one is the reason for this drop.
481
+ const error = this.lastTransportError;
482
+ this.lastTransportError = undefined;
483
+ this._state = willReconnect ? "reconnecting" : "failed";
484
+ this.emitEvent(MCPClientEvent.DISCONNECTED, {
485
+ ...(error ? { error } : {}),
486
+ deliberate: false,
487
+ willReconnect,
488
+ });
489
+ if (willReconnect)
490
+ this.startReconnectLoop();
491
+ }
492
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
493
+ handleTransportError(sdkClient, error) {
494
+ if (this.sdkClient !== null && this.sdkClient !== sdkClient)
495
+ return;
496
+ this.lastTransportError = error instanceof Error ? error : new Error(String(error));
497
+ this.emitError(error);
498
+ }
499
+ /**
500
+ * Retry {@link establish} with exponential backoff until it succeeds, the retry
501
+ * budget runs out, or {@link disconnect} is called.
502
+ */
503
+ startReconnectLoop() {
504
+ if (this.reconnectPromise)
505
+ return;
506
+ this.reconnectPromise = (async () => {
507
+ while (!this.closedByUser) {
508
+ const attempt = ++this.reconnectAttempts;
509
+ const delayMs = Math.min(this.reconnectOptions.initialDelayMs *
510
+ Math.pow(this.reconnectOptions.backoffFactor, attempt - 1), this.reconnectOptions.maxDelayMs);
511
+ this.emitEvent(MCPClientEvent.RECONNECTING, { attempt, delayMs });
512
+ await this.waitBeforeReconnect(delayMs);
513
+ if (this.closedByUser)
514
+ return;
515
+ try {
516
+ await this.establish();
517
+ this._state = "connected";
518
+ this.reconnectAttempts = 0;
519
+ this.emitEvent(MCPClientEvent.RECONNECTED, { tools: this._tools });
520
+ return;
521
+ }
522
+ catch (error) {
523
+ this.emitError(error);
524
+ if (attempt >= this.reconnectOptions.maxRetries) {
525
+ this._state = "failed";
526
+ return;
527
+ }
528
+ }
208
529
  }
530
+ })().finally(() => {
531
+ this.reconnectPromise = null;
532
+ });
533
+ }
534
+ waitBeforeReconnect(delayMs) {
535
+ return new Promise((resolve) => {
536
+ this.reconnectWake = resolve;
537
+ this.reconnectTimer = setTimeout(() => {
538
+ this.reconnectTimer = null;
539
+ this.reconnectWake = null;
540
+ resolve();
541
+ }, delayMs);
542
+ });
543
+ }
544
+ cancelReconnectWait() {
545
+ if (this.reconnectTimer) {
546
+ clearTimeout(this.reconnectTimer);
547
+ this.reconnectTimer = null;
209
548
  }
210
- finally {
211
- this.sdkClient = null;
212
- this.sdkTransport = null;
213
- this._tools = [];
214
- this.connected = false;
549
+ if (this.reconnectWake) {
550
+ const wake = this.reconnectWake;
551
+ this.reconnectWake = null;
552
+ wake();
553
+ }
554
+ }
555
+ /**
556
+ * `EventEmitter` throws when an `error` event has no listener, which would turn
557
+ * a background transport hiccup into a process crash. Only emit when someone is
558
+ * actually listening.
559
+ */
560
+ emitError(error) {
561
+ if (this.listenerCount(MCPClientEvent.ERROR) === 0)
562
+ return;
563
+ this.emit(MCPClientEvent.ERROR, error instanceof Error ? error : new Error(String(error)));
564
+ }
565
+ emitEvent(event, payload) {
566
+ this.emit(event, payload);
567
+ }
568
+ /**
569
+ * Resolve the effective request options for a call: client defaults (static or
570
+ * per-call), with explicit per-call options layered on top.
571
+ */
572
+ resolveCallOptions(context, overrides) {
573
+ const defaults = typeof this.callOptions === "function" ? this.callOptions(context) : this.callOptions;
574
+ const merged = { ...defaults, ...overrides };
575
+ const hasOption = Object.values(merged).some((value) => value !== undefined);
576
+ return hasOption ? merged : undefined;
577
+ }
578
+ /**
579
+ * Wait for the client to become usable, then issue the call. Wrapping every
580
+ * transport-level failure keeps the error surface stable, while `cause` lets a
581
+ * host tell an abort apart from a server error.
582
+ */
583
+ async invokeTool(name, input, overrides) {
584
+ const callOptions = this.resolveCallOptions({ toolName: name, input }, overrides);
585
+ const sdkClient = await this.awaitUsableClient(name, callOptions?.signal);
586
+ try {
587
+ const params = { name, arguments: input };
588
+ return callOptions
589
+ ? await sdkClient.callTool(params, undefined, callOptions)
590
+ : await sdkClient.callTool(params);
591
+ }
592
+ catch (error) {
593
+ const message = error instanceof Error ? error.message : String(error);
594
+ throw new errors_1.MCPCallError(`MCPClient: Tool "${name}" execution failed: ${message}`, name, error);
595
+ }
596
+ }
597
+ /**
598
+ * Return the SDK client once one is usable. While a reconnect is in flight the
599
+ * call waits for it rather than failing outright, so a transient drop does not
600
+ * surface as a tool error — but an aborted signal still wins immediately.
601
+ */
602
+ async awaitUsableClient(toolName, signal
603
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
604
+ ) {
605
+ if (signal?.aborted) {
606
+ throw new errors_1.MCPCallError(`MCPClient: Tool "${toolName}" execution failed: request was aborted`, toolName, signal.reason);
607
+ }
608
+ if (this._state === "reconnecting" && this.reconnectPromise) {
609
+ await this.raceAbort(this.reconnectPromise, toolName, signal);
215
610
  }
611
+ if (this._state !== "connected" || !this.sdkClient) {
612
+ throw new errors_1.MCPNotConnectedError(`MCPClient: Cannot execute tool "${toolName}" — client is not connected`, toolName, this._state);
613
+ }
614
+ return this.sdkClient;
615
+ }
616
+ raceAbort(promise, toolName, signal) {
617
+ if (!signal)
618
+ return promise;
619
+ return new Promise((resolve, reject) => {
620
+ const onAbort = () => reject(new errors_1.MCPCallError(`MCPClient: Tool "${toolName}" execution failed: request was aborted`, toolName, signal.reason));
621
+ signal.addEventListener("abort", onAbort, { once: true });
622
+ promise
623
+ .then(resolve, reject)
624
+ .finally(() => signal.removeEventListener("abort", onAbort));
625
+ });
216
626
  }
217
627
  wrapMcpTool(mcpTool) {
218
628
  const inputSchema = {
@@ -225,37 +635,16 @@ class MCPClient {
225
635
  description: mcpTool.description ?? mcpTool.name,
226
636
  inputSchema,
227
637
  execute: async (input) => {
228
- if (!this.connected || !this.sdkClient) {
229
- throw new Error(`MCPClient: Cannot execute tool "${mcpTool.name}" client is not connected`);
230
- }
231
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
232
- let result;
233
- try {
234
- result = await this.sdkClient.callTool({
235
- name: mcpTool.name,
236
- arguments: input,
237
- });
238
- }
239
- catch (error) {
240
- const message = error instanceof Error ? error.message : String(error);
241
- throw new Error(`MCPClient: Tool "${mcpTool.name}" execution failed: ${message}`);
242
- }
243
- // Extract text content items from MCP result
244
- if (result?.content && Array.isArray(result.content)) {
245
- const textItems = result.content
246
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
247
- .filter((item) => item.type === "text")
248
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
249
- .map((item) => item.text);
250
- if (textItems.length > 0) {
251
- return textItems.length === 1 ? textItems[0] : textItems.join("\n");
252
- }
253
- }
254
- // Fall back to structured content or full JSON serialization
255
- if (result?.structuredContent !== undefined) {
256
- return result.structuredContent;
638
+ const result = await this.invokeTool(mcpTool.name, input ?? {});
639
+ const context = { toolName: mcpTool.name, input: input ?? {} };
640
+ if (result?.isError && this.throwOnToolError) {
641
+ const rendered = (0, content_1.renderToolResult)(result);
642
+ const detail = typeof rendered === "string" ? rendered : JSON.stringify(rendered);
643
+ throw new errors_1.MCPToolError(`MCPClient: Tool "${mcpTool.name}" reported an error: ${detail}`, mcpTool.name, result);
257
644
  }
258
- return JSON.stringify(result ?? null);
645
+ return this.formatResult
646
+ ? this.formatResult(result, context)
647
+ : (0, content_1.renderToolResult)(result);
259
648
  },
260
649
  });
261
650
  }
@@ -0,0 +1,28 @@
1
+ import type { MCPCallToolResult, MCPContentBlock } from "./types";
2
+ /**
3
+ * Render a single MCP content block as text.
4
+ *
5
+ * Text blocks are returned verbatim. Binary blocks (image, audio, blob
6
+ * resources) cannot be represented as text, so they are rendered as a
7
+ * descriptive placeholder carrying their mime type and size — enough for a model
8
+ * to know the content exists and to ask for it another way. Text-bearing
9
+ * resources are inlined under a header naming their URI.
10
+ *
11
+ * Unrecognised block types — content types added to the protocol after this
12
+ * release — are serialised as JSON rather than dropped.
13
+ */
14
+ export declare function renderContentBlock(block: MCPContentBlock): string;
15
+ /**
16
+ * Convert a raw MCP `CallToolResult` into the value handed to the agent.
17
+ *
18
+ * Resolution order:
19
+ * 1. When the result has content blocks, every block is rendered and the
20
+ * segments are joined with newlines. `structuredContent` is appended as JSON
21
+ * when the result carries no text block, so structured output is never lost
22
+ * behind a binary-only result.
23
+ * 2. Otherwise `structuredContent` is returned as-is, so tools with an
24
+ * `outputSchema` keep giving the agent a real object.
25
+ * 3. Otherwise the whole result is JSON-serialised.
26
+ */
27
+ export declare function renderToolResult(result: MCPCallToolResult | null | undefined): unknown;
28
+ //# sourceMappingURL=content.d.ts.map