@mastra/mcp 2.8.0-alpha.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -15397,6 +15397,57 @@ var TaskRequester = class {
15397
15397
  };
15398
15398
  }
15399
15399
  };
15400
+ // src/client/tasks/suspend.ts
15401
+ const MCP_INPUT_REQUEST_SUSPEND_TYPE = "mcp-input-request";
15402
+ const SuspendPayloadSchema = z.object({
15403
+ type: z.literal(MCP_INPUT_REQUEST_SUSPEND_TYPE),
15404
+ serverName: z.string(),
15405
+ toolName: z.string(),
15406
+ taskId: z.string().min(1),
15407
+ createdAt: z.string(),
15408
+ requestedAt: z.string().optional(),
15409
+ requests: TaskInputRequestsSchema
15410
+ });
15411
+ const ResumeDataSchema = z.object({ inputResponses: z.record(z.string(), z.object({
15412
+ action: z.enum([
15413
+ "accept",
15414
+ "decline",
15415
+ "cancel"
15416
+ ]),
15417
+ content: z.record(z.string(), z.unknown()).optional()
15418
+ })) });
15419
+ /**
15420
+ * Reads a resume of a tool call that suspended on MCP task input. Returns `undefined` when the call
15421
+ * didn't suspend that way, so other resumes (tool approval, for one) take the normal path.
15422
+ */
15423
+ function readInputRequestResume(host, expected) {
15424
+ const raw = host?.suspendPayload;
15425
+ if (!raw || typeof raw !== "object" || raw.type !== "mcp-input-request") return;
15426
+ const payload = SuspendPayloadSchema.safeParse(raw);
15427
+ if (!payload.success) return {
15428
+ ok: false,
15429
+ reason: `the suspended MCP input request is malformed`
15430
+ };
15431
+ if (payload.data.serverName !== expected.serverName || payload.data.toolName !== expected.toolName) return {
15432
+ ok: false,
15433
+ reason: `the suspended MCP input request belongs to another tool`
15434
+ };
15435
+ const resume = ResumeDataSchema.safeParse(host?.resumeData);
15436
+ if (!resume.success) return {
15437
+ ok: false,
15438
+ reason: `resume data must be { inputResponses: { [key]: { action: 'accept' | 'decline' | 'cancel', content? } } }`
15439
+ };
15440
+ const missing = Object.keys(payload.data.requests).filter((key) => !Object.hasOwn(resume.data.inputResponses, key));
15441
+ if (missing.length > 0) return {
15442
+ ok: false,
15443
+ reason: `resume data has no answer for ${missing.join(", ")}`
15444
+ };
15445
+ return {
15446
+ ok: true,
15447
+ payload: payload.data,
15448
+ inputResponses: resume.data.inputResponses
15449
+ };
15450
+ }
15400
15451
  // src/client/tasks/wait.ts
15401
15452
  /** Why a task wait stopped without a terminal task. */
15402
15453
  var TaskWaitError = class extends Error {
@@ -15425,12 +15476,6 @@ const defaultSleep = (ms, signal) => new Promise((resolve, reject) => {
15425
15476
  };
15426
15477
  signal?.addEventListener("abort", onAbort, { once: true });
15427
15478
  });
15428
- /**
15429
- * Polls a remote task until it reaches a terminal status, answering its input requests on the way.
15430
- *
15431
- * Aborting the signal or exceeding `maxWaitMs` sends `tasks/cancel` (fire-and-forget) and rejects.
15432
- * Each input request key is answered at most once, however many polls list it.
15433
- */
15434
15479
  async function waitForTask(port, created, options = {}) {
15435
15480
  const sleep = options.sleep ?? defaultSleep;
15436
15481
  const now = options.now ?? Date.now;
@@ -15481,7 +15526,21 @@ async function waitForTask(port, created, options = {}) {
15481
15526
  const { inputRequests } = task;
15482
15527
  const pending = Object.entries(inputRequests).filter(([key]) => !answered.has(key));
15483
15528
  if (pending.length > 0) {
15484
- const responses = await bounded(answerInputRequests(pending, task, options.inputRequests, stop.signal));
15529
+ const supplied = options.inputResponses ?? {};
15530
+ const unanswered = pending.filter(([key]) => !Object.hasOwn(supplied, key));
15531
+ const responses = {};
15532
+ for (const [key] of pending) if (Object.hasOwn(supplied, key)) responses[key] = supplied[key];
15533
+ if (unanswered.length > 0 && !options.inputRequests && options.stopAtInput) {
15534
+ if (Object.keys(responses).length > 0) {
15535
+ for (const key of Object.keys(responses)) answered.add(key);
15536
+ await bounded(port.update(task.taskId, responses, { signal: stop.signal }));
15537
+ }
15538
+ return {
15539
+ ...task,
15540
+ inputRequests: Object.fromEntries(unanswered)
15541
+ };
15542
+ }
15543
+ if (unanswered.length > 0) Object.assign(responses, await bounded(answerInputRequests(unanswered, task, options.inputRequests, stop.signal)));
15485
15544
  for (const key of Object.keys(responses)) answered.add(key);
15486
15545
  await bounded(port.update(task.taskId, responses, { signal: stop.signal }));
15487
15546
  task = await poll();
@@ -17001,7 +17060,43 @@ var InternalMastraMCPClient = class extends MastraBase {
17001
17060
  if (serverMeta.connectFirst) await this.connect();
17002
17061
  const operationContext = context?.requestContext ?? null;
17003
17062
  return this.operationContextStore.run(operationContext, async () => {
17063
+ const suspendHost = context?.agent ?? context?.workflow;
17064
+ const toolCallSpan = context?.tracingContext?.currentSpan;
17004
17065
  const executeToolCall = async () => {
17066
+ const resume = this.taskOptions ? readInputRequestResume(suspendHost, {
17067
+ serverName: this.name,
17068
+ toolName: tool.name
17069
+ }) : void 0;
17070
+ if (resume) {
17071
+ if (!resume.ok) throw new MastraError({
17072
+ id: "MCP_CLIENT_TOOL_TASK_RESUME_INVALID",
17073
+ domain: ErrorDomain.MCP,
17074
+ category: ErrorCategory.USER,
17075
+ text: `Can't resume MCP tool ${tool.name}: ${resume.reason}`,
17076
+ details: {
17077
+ toolName: tool.name,
17078
+ serverName: this.name
17079
+ }
17080
+ });
17081
+ const { payload, inputResponses } = resume;
17082
+ this.log("debug", `Resuming tool ${tool.name} on MCP task ${payload.taskId}`);
17083
+ toolCallSpan?.update({ metadata: { mcpTaskKey: taskKeyOf(payload.taskId) } });
17084
+ const waiting = {
17085
+ taskId: payload.taskId,
17086
+ status: "input_required",
17087
+ createdAt: payload.createdAt,
17088
+ lastUpdatedAt: payload.createdAt,
17089
+ ttlMs: null,
17090
+ inputRequests: payload.requests
17091
+ };
17092
+ const res = await this.awaitToolTask(tool.name, waiting, {
17093
+ signal: context?.abortSignal,
17094
+ writer: context?.writer,
17095
+ suspend: suspendHost?.suspend,
17096
+ inputResponses
17097
+ });
17098
+ return res && finishToolCall(res);
17099
+ }
17005
17100
  this.log("debug", `Executing tool: ${tool.name}`, {
17006
17101
  toolArgs: input,
17007
17102
  runId: context?.runId
@@ -17019,13 +17114,17 @@ var InternalMastraMCPClient = class extends MastraBase {
17019
17114
  timeout: this.timeout,
17020
17115
  signal: context?.abortSignal
17021
17116
  }));
17022
- const toolCallSpan = context?.tracingContext?.currentSpan;
17023
17117
  if (toolCallSpan && serverSpan) toolCallSpan.links = [...toolCallSpan.links ?? [], serverSpan];
17024
17118
  const task = this.taskOptions ? takeTaskHandle(res) : void 0;
17025
17119
  if (!task) return finishToolCall(res);
17026
17120
  this.log("debug", `Tool ${tool.name} continues as MCP task ${task.taskId}`);
17027
17121
  toolCallSpan?.update({ metadata: { mcpTaskKey: taskKeyOf(task.taskId) } });
17028
- return finishToolCall(await this.awaitToolTask(tool.name, task, context?.abortSignal, context?.writer));
17122
+ const taskResult = await this.awaitToolTask(tool.name, task, {
17123
+ signal: context?.abortSignal,
17124
+ writer: context?.writer,
17125
+ suspend: suspendHost?.suspend
17126
+ });
17127
+ return taskResult && finishToolCall(taskResult);
17029
17128
  };
17030
17129
  const finishToolCall = async (res) => {
17031
17130
  if (res.isError && this.onToolError === "throw") {
@@ -17112,8 +17211,13 @@ var InternalMastraMCPClient = class extends MastraBase {
17112
17211
  maxRetries: 0
17113
17212
  };
17114
17213
  }
17115
- /** Waits for a remote task created by a tool call and returns its `CallToolResult`. */
17116
- async awaitToolTask(toolName, task, signal, writer) {
17214
+ /**
17215
+ * Waits for a remote task created by a tool call and returns its `CallToolResult`.
17216
+ *
17217
+ * When the task needs input that no `inputRequests` handler answers and the caller can `suspend`,
17218
+ * suspends with the requests and returns `undefined`, leaving the task open for the answers.
17219
+ */
17220
+ async awaitToolTask(toolName, task, { signal, writer, suspend, inputResponses }) {
17117
17221
  const details = {
17118
17222
  toolName,
17119
17223
  serverName: this.name,
@@ -17152,6 +17256,8 @@ var InternalMastraMCPClient = class extends MastraBase {
17152
17256
  }, task, {
17153
17257
  signal,
17154
17258
  inputRequests: this.serverConfig.inputRequests,
17259
+ stopAtInput: !this.serverConfig.inputRequests && suspend !== void 0,
17260
+ inputResponses,
17155
17261
  pollIntervalMs: this.taskOptions?.pollIntervalMs,
17156
17262
  maxWaitMs: this.taskOptions?.maxWaitMs,
17157
17263
  onStatus
@@ -17163,6 +17269,17 @@ var InternalMastraMCPClient = class extends MastraBase {
17163
17269
  throw fail("MCP_CLIENT_TOOL_TASK_POLL_FAILED", `Polling MCP task ${task.taskId} failed: ${reason}`, error);
17164
17270
  }
17165
17271
  switch (terminal.status) {
17272
+ case "input_required":
17273
+ await suspend({
17274
+ type: MCP_INPUT_REQUEST_SUSPEND_TYPE,
17275
+ serverName: this.name,
17276
+ toolName,
17277
+ taskId: terminal.taskId,
17278
+ createdAt: terminal.createdAt,
17279
+ requestedAt: (/* @__PURE__ */ new Date()).toISOString(),
17280
+ requests: terminal.inputRequests
17281
+ });
17282
+ return;
17166
17283
  case "completed": return terminal.result;
17167
17284
  case "cancelled": throw fail("MCP_CLIENT_TOOL_TASK_CANCELLED", terminal.statusMessage ?? `MCP task ${task.taskId} was cancelled`);
17168
17285
  case "failed": throw fail("MCP_CLIENT_TOOL_TASK_FAILED", (typeof terminal.error.message === "string" ? terminal.error.message : void 0) ?? terminal.statusMessage ?? `MCP task ${task.taskId} failed`);
@@ -20905,6 +21022,161 @@ function echoes(body, challenge) {
20905
21022
  const b = Buffer.from(challenge);
20906
21023
  return a.length === b.length && timingSafeEqual(a, b);
20907
21024
  }
21025
+ // src/server/model-output-to-mcp.ts
21026
+ const DATA_URI = /^data:([^;,]*)((?:;[^;,]*)*),(.*)$/is;
21027
+ /**
21028
+ * Maps the value a Mastra tool's `toModelOutput` returns (an AI SDK tool-result output such as
21029
+ * `{ type: 'text' }`, `{ type: 'json' }` or `{ type: 'content', value: [...] }`) to MCP content blocks.
21030
+ *
21031
+ * Returns `undefined` for any other shape, meaning the caller keeps its default result.
21032
+ */
21033
+ function modelOutputToMcpContent(value, toolName) {
21034
+ if (!isRecord(value)) return void 0;
21035
+ switch (value.type) {
21036
+ case "text": return typeof value.value === "string" ? success([{
21037
+ type: "text",
21038
+ text: value.value
21039
+ }]) : void 0;
21040
+ case "json": return success([{
21041
+ type: "text",
21042
+ text: stringify(value.value)
21043
+ }]);
21044
+ case "error-text": return typeof value.value === "string" ? {
21045
+ isError: true,
21046
+ content: [{
21047
+ type: "text",
21048
+ text: value.value
21049
+ }]
21050
+ } : void 0;
21051
+ case "error-json": return {
21052
+ isError: true,
21053
+ content: [{
21054
+ type: "text",
21055
+ text: stringify(value.value)
21056
+ }]
21057
+ };
21058
+ case "content": return Array.isArray(value.value) ? success(value.value.map((part, index) => partToBlock(part, toolName, index))) : void 0;
21059
+ default: return;
21060
+ }
21061
+ }
21062
+ function partToBlock(part, toolName, index) {
21063
+ if (!isRecord(part)) return {
21064
+ type: "text",
21065
+ text: stringify(part)
21066
+ };
21067
+ switch (part.type) {
21068
+ case "text":
21069
+ if (typeof part.text === "string") return {
21070
+ type: "text",
21071
+ text: part.text
21072
+ };
21073
+ break;
21074
+ case "media":
21075
+ case "image-data":
21076
+ case "file-data": {
21077
+ const fallback = part.type === "image-data" ? "image/jpeg" : "application/octet-stream";
21078
+ const mediaType = typeof part.mediaType === "string" && part.mediaType ? part.mediaType : void 0;
21079
+ if (typeof part.data === "string") {
21080
+ const uri = parseDataUri(part.data);
21081
+ if (uri) return mediaBlock(uri.data, mediaType ?? (uri.mediaType || fallback), toolName, index);
21082
+ if (/^data:/i.test(part.data)) break;
21083
+ return mediaBlock(part.data, mediaType ?? fallback, toolName, index);
21084
+ }
21085
+ if (part.data instanceof Uint8Array) return mediaBlock(Buffer.from(part.data).toString("base64"), mediaType ?? fallback, toolName, index);
21086
+ break;
21087
+ }
21088
+ case "image-url":
21089
+ case "file-url": {
21090
+ if (typeof part.url !== "string") break;
21091
+ const uri = parseDataUri(part.url);
21092
+ if (!uri) return {
21093
+ type: "text",
21094
+ text: part.url
21095
+ };
21096
+ const fallback = part.type === "image-url" ? "image/jpeg" : "application/octet-stream";
21097
+ const mediaType = typeof part.mediaType === "string" && part.mediaType ? part.mediaType : void 0;
21098
+ return mediaBlock(uri.data, mediaType ?? (uri.mediaType || fallback), toolName, index);
21099
+ }
21100
+ }
21101
+ return {
21102
+ type: "text",
21103
+ text: stringify(part)
21104
+ };
21105
+ }
21106
+ function mediaBlock(data, mediaType, toolName, index) {
21107
+ const separator = mediaType.indexOf(";");
21108
+ const mimeType = separator === -1 ? mediaType.toLowerCase() : mediaType.slice(0, separator).toLowerCase() + mediaType.slice(separator);
21109
+ if (mimeType.startsWith("image/")) return {
21110
+ type: "image",
21111
+ data,
21112
+ mimeType
21113
+ };
21114
+ if (mimeType.startsWith("audio/")) return {
21115
+ type: "audio",
21116
+ data,
21117
+ mimeType
21118
+ };
21119
+ return {
21120
+ type: "resource",
21121
+ resource: {
21122
+ uri: `mastra://tool-output/${encodeURIComponent(toolName)}/${index}`,
21123
+ mimeType,
21124
+ blob: data
21125
+ }
21126
+ };
21127
+ }
21128
+ /**
21129
+ * Splits a `data:` URI into its media type (with parameters such as `charset`, minus the `base64`
21130
+ * marker) and Base64 payload. Returns `undefined` if it can't be decoded.
21131
+ */
21132
+ function parseDataUri(value) {
21133
+ const match = DATA_URI.exec(value);
21134
+ if (!match) return void 0;
21135
+ const [, type = "", params = "", payload = ""] = match;
21136
+ const parameters = params.split(";").map((param) => param.trim()).filter(Boolean);
21137
+ const base64 = parameters.some((param) => param.toLowerCase() === "base64");
21138
+ let data = payload;
21139
+ if (!base64) {
21140
+ const bytes = decodePercentEncoded(payload);
21141
+ if (!bytes) return void 0;
21142
+ data = bytes.toString("base64");
21143
+ }
21144
+ const mediaType = type.trim().toLowerCase();
21145
+ const kept = parameters.filter((param) => param.toLowerCase() !== "base64");
21146
+ return {
21147
+ mediaType: mediaType && kept.length ? [mediaType, ...kept].join(";") : mediaType,
21148
+ data
21149
+ };
21150
+ }
21151
+ /** Decodes `%XX` escapes as raw octets (RFC 2397), not UTF-8 text. Returns `undefined` for a malformed escape. */
21152
+ function decodePercentEncoded(payload) {
21153
+ const input = Buffer.from(payload, "utf8");
21154
+ const output = Buffer.alloc(input.length);
21155
+ let length = 0;
21156
+ for (let index = 0; index < input.length; index++) {
21157
+ if (input[index] !== 37) {
21158
+ output[length++] = input[index];
21159
+ continue;
21160
+ }
21161
+ const hex = input.toString("latin1", index + 1, index + 3);
21162
+ if (!/^[0-9a-f]{2}$/i.test(hex)) return void 0;
21163
+ output[length++] = Number.parseInt(hex, 16);
21164
+ index += 2;
21165
+ }
21166
+ return output.subarray(0, length);
21167
+ }
21168
+ function success(content) {
21169
+ return {
21170
+ isError: false,
21171
+ content
21172
+ };
21173
+ }
21174
+ function stringify(value) {
21175
+ return JSON.stringify(value) ?? String(value);
21176
+ }
21177
+ function isRecord(value) {
21178
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21179
+ }
20908
21180
  // src/server/tasks/store.ts
20909
21181
  /** How a principal is stored and shown. Principals can carry user and subject ids, so only their hash is kept. */
20910
21182
  function principalKeyOf(principal) {
@@ -22634,6 +22906,7 @@ var MCPServer = class extends MCPServerBase {
22634
22906
  tasksInspector;
22635
22907
  webhooks;
22636
22908
  legacy;
22909
+ toolResultOptions;
22637
22910
  tasks;
22638
22911
  /** Where tasks are kept: `tasks.store`, or the store picked on first use. */
22639
22912
  taskStore;
@@ -22657,6 +22930,7 @@ var MCPServer = class extends MCPServerBase {
22657
22930
  this.requestStateCodec = this.createRequestStateCodec(config.requestState);
22658
22931
  this.eventsOptions = config.eventsOptions ?? {};
22659
22932
  this.legacy = config.legacy ?? "reject";
22933
+ this.toolResultOptions = config.toolResult ?? {};
22660
22934
  this.tasks = resolveTasksConfig(config.tasks, {
22661
22935
  tools: Object.keys(config.tools ?? {}),
22662
22936
  workflows: Object.keys(config.workflows ?? {}),
@@ -23277,7 +23551,7 @@ var MCPServer = class extends MCPServerBase {
23277
23551
  resumeSchema: execution.resumeSchema
23278
23552
  });
23279
23553
  }
23280
- const result = server.projectCallToolResult(this.toCallToolResult(name, tool, execution.output), this.toolInfo(name, tool).outputSchema);
23554
+ const result = server.projectCallToolResult(await this.completedToolResult(name, tool, execution.output, requestContext), this.toolInfo(name, tool).outputSchema);
23281
23555
  this.logger.info(`Tool '${name}' finished in ${Date.now() - startedAt}ms.`);
23282
23556
  return result;
23283
23557
  } catch (error) {
@@ -23344,7 +23618,7 @@ var MCPServer = class extends MCPServerBase {
23344
23618
  suspendPayload: options.suspendPayload
23345
23619
  },
23346
23620
  initialAttempt: () => ({
23347
- outcome: settled.then((outcome) => "error" in outcome ? this.toolErrorOutcome(name, outcome.error) : this.toTaskOutcome(name, tool, outcome.execution)),
23621
+ outcome: settled.then((outcome) => "error" in outcome ? this.toolErrorOutcome(name, outcome.error) : this.toTaskOutcome(name, tool, outcome.execution, requestContext)),
23348
23622
  abort: (reason) => controller.abort(reason)
23349
23623
  })
23350
23624
  }) };
@@ -23455,7 +23729,7 @@ var MCPServer = class extends MCPServerBase {
23455
23729
  suspendPayload,
23456
23730
  tracingContext: span ? { currentSpan: span } : void 0
23457
23731
  });
23458
- return this.toTaskOutcome(name, tool, execution);
23732
+ return await this.toTaskOutcome(name, tool, execution, requestContext);
23459
23733
  } catch (error) {
23460
23734
  return this.toolErrorOutcome(name, error);
23461
23735
  }
@@ -23539,7 +23813,8 @@ var MCPServer = class extends MCPServerBase {
23539
23813
  }
23540
23814
  };
23541
23815
  }
23542
- toTaskOutcome(name, tool, execution) {
23816
+ /** Never rejects: a failure building the result completes the task with the tool error result. */
23817
+ async toTaskOutcome(name, tool, execution, requestContext) {
23543
23818
  if (execution.status === "suspended") {
23544
23819
  this.logger.debug(`Tool task '${name}' requires client input.`);
23545
23820
  return {
@@ -23549,7 +23824,11 @@ var MCPServer = class extends MCPServerBase {
23549
23824
  };
23550
23825
  }
23551
23826
  if (tool.mcp?.toolType === "agent" && execution.output?.finishReason === "suspended") return completed(errorResult$1(`Agent '${name.slice(4)}' paused for tool approval or input, which MCP tasks don't support yet`));
23552
- return completed(this.toCallToolResult(name, tool, execution.output));
23827
+ try {
23828
+ return completed(await this.completedToolResult(name, tool, execution.output, requestContext));
23829
+ } catch (error) {
23830
+ return this.toolErrorOutcome(name, error);
23831
+ }
23553
23832
  }
23554
23833
  /** A tool that throws inside a task completes it with `isError`, as a synchronous call would answer. */
23555
23834
  toolErrorOutcome(name, error) {
@@ -23864,6 +24143,34 @@ var MCPServer = class extends MCPServerBase {
23864
24143
  output
23865
24144
  };
23866
24145
  }
24146
+ /**
24147
+ * The result of a tool call that completed: the default result, built from `toModelOutput` when
24148
+ * `toolResult.modelOutput` is on, then passed through `toolResult.transform`. Throws what
24149
+ * `toModelOutput` or `transform` throw.
24150
+ */
24151
+ async completedToolResult(name, tool, output, requestContext) {
24152
+ let result = this.toCallToolResult(name, tool, output);
24153
+ const { modelOutput = false, transform } = this.toolResultOptions;
24154
+ if (modelOutput && !result.isError && tool.toModelOutput && output != null) {
24155
+ const mapped = modelOutputToMcpContent(await tool.toModelOutput(output), name);
24156
+ if (mapped?.isError) result = {
24157
+ isError: true,
24158
+ content: mapped.content,
24159
+ ...result._meta ? { _meta: result._meta } : {}
24160
+ };
24161
+ else if (mapped) result = {
24162
+ ...result,
24163
+ content: result.structuredContent !== void 0 ? [...result.content, ...mapped.content] : mapped.content
24164
+ };
24165
+ }
24166
+ if (transform) result = await transform({
24167
+ toolName: name,
24168
+ output,
24169
+ result,
24170
+ requestContext
24171
+ }) ?? result;
24172
+ return result;
24173
+ }
23867
24174
  toCallToolResult(name, tool, value) {
23868
24175
  if (isValidationError(value)) {
23869
24176
  this.logger.warn(`Tool '${name}' rejected its input.`, { error: value.message });