@salesforce/sfdx-agent-harness-mastra 0.17.0 → 0.19.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/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-harness-mastra` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.19.0] - 2026-06-12
7
+
8
+ ### Features
9
+ - **agent-sdk,harness-mastra,harness-claude**: add tool-call-delta ChatEvent for streaming tool-call args @W-22965697@ ([#597](https://github.com/forcedotcom/agentic-dx/pull/597))
10
+ - **agent-sdk,harness-mastra,harness-claude**: add tool-progress ChatEvent variant @W-22951127@ ([#594](https://github.com/forcedotcom/agentic-dx/pull/594))
11
+
12
+ ### Fixes
13
+ - **harness-claude**: map SDKResultSuccess.is_error to ChatEvent.error ([#593](https://github.com/forcedotcom/agentic-dx/pull/593))
14
+
15
+ ## [0.18.0] - 2026-06-11
16
+
17
+ ### Fixes
18
+ - **harness-mastra**: drive one resume per pending approval for parallel batches ([#587](https://github.com/forcedotcom/agentic-dx/pull/587))
19
+
6
20
  ## [0.17.0] - 2026-06-09
7
21
 
8
22
  ### Fixes
@@ -143,12 +143,34 @@ export declare class MastraApprovalCoordinator {
143
143
  */
144
144
  private currentRunId;
145
145
  /**
146
- * The toolCallId Mastra is currently suspended on (after the natural
147
- * `tool-call-approval` chunk). `null` when no suspension. The
148
- * coordinator's pump loop reads this when the current `fullStream`
149
- * closes to decide whether to drive a resume.
146
+ * `toolCallId` of the MCP tool call currently in-flight on this turn,
147
+ * or `null` when no MCP tool is awaiting a result. Tracked at
148
+ * `routeEnriched` time: set when a MCP-namespaced `tool-call` arrives,
149
+ * cleared on the matching `tool-result`.
150
+ *
151
+ * Used by {@link emitMcpProgress} to attribute incoming
152
+ * `notifications/progress` (which only carry a `progressToken`) to the
153
+ * right `toolCallId` for the `tool-progress` ChatEvent.
154
+ *
155
+ * Mastra serializes MCP tool execution per workflow step, so at most
156
+ * one MCP tool call is in flight per coordinator at any time —
157
+ * matching the "currentMcpToolCallId" field's scalar shape.
158
+ */
159
+ private currentMcpToolCallId;
160
+ /**
161
+ * Captures the (Mastra-namespaced) tool name for each `toolCallId`
162
+ * routed through `routeEnriched`. Used by {@link emitMcpProgress} to
163
+ * stamp `toolName` on the `tool-progress` ChatEvent so AG-UI Activity
164
+ * translators don't have to consult the originating `tool-call` event
165
+ * to route progress chunks.
166
+ */
167
+ private readonly toolNameForCallId;
168
+ /**
169
+ * ToolCallIds Mastra is currently suspended on. Mastra retains a separate
170
+ * workflow snapshot step per id, so the pump must drive one resume per
171
+ * pending id with `{ toolCallId }` to disambiguate (#586).
150
172
  */
151
- private currentSuspendId;
173
+ private readonly pendingSuspendIds;
152
174
  /** Consumer-tool: bound parkers, keyed by `toolCallId`. `submitToolResult` resolves these. */
153
175
  private readonly pendingByToolCallId;
154
176
  /**
@@ -176,6 +198,33 @@ export declare class MastraApprovalCoordinator {
176
198
  private timedOut;
177
199
  constructor(opts: MastraApprovalCoordinatorOptions);
178
200
  initialSink(): GenSink<ChatEvent>;
201
+ /**
202
+ * Mastra's current run id. The harness's MCP-progress dispatcher uses
203
+ * this to filter incoming `notifications/progress` to the coordinator
204
+ * whose run produced the originating tool call (the upstream server
205
+ * received the progressToken Mastra injected from `context.runId`).
206
+ */
207
+ getCurrentRunId(): string;
208
+ /**
209
+ * Issue #591: dispatch an MCP `notifications/progress` notification
210
+ * received via `mcpClient.progress.onUpdate(...)` to the consumer's
211
+ * eventStream as a `tool-progress` ChatEvent.
212
+ *
213
+ * Caller (the harness MCP-progress listener) is responsible for
214
+ * filtering by `progressToken === coordinator.getCurrentRunId()` so this
215
+ * method only sees notifications belonging to its own turn.
216
+ *
217
+ * Drops the notification (no event surfaced) when no MCP tool call is
218
+ * currently in flight — the only path that produces `currentMcpToolCallId
219
+ * === null` after a turn started is "progress arrived after the matching
220
+ * tool-result." Losing a stragglier is preferable to surfacing a
221
+ * progress event without a meaningful toolCallId.
222
+ */
223
+ emitMcpProgress(params: {
224
+ progress: number;
225
+ total?: number;
226
+ message?: string;
227
+ }): void;
179
228
  onTeardown(callback: () => void): void;
180
229
  private runTeardownCallbacks;
181
230
  /**
@@ -5,6 +5,13 @@
5
5
  import { AgentSDKError, AgentSDKErrorType, } from '@salesforce/sfdx-agent-sdk';
6
6
  import { GenSink } from '@salesforce/sfdx-agent-sdk/harness';
7
7
  import { MastraEventAdapter } from './mastra-event-adapter.js';
8
+ function pickIdWithDecision(pendingIds, decisions) {
9
+ for (const id of pendingIds) {
10
+ if (decisions.has(id))
11
+ return id;
12
+ }
13
+ return undefined;
14
+ }
8
15
  /**
9
16
  * Per-`(agentId, threadId)` coordinator that subsumes the pre-#529
10
17
  * `MastraStreamBroker` + `activeRuns` pair into one object owning a single
@@ -72,12 +79,34 @@ export class MastraApprovalCoordinator {
72
79
  */
73
80
  currentRunId = '';
74
81
  /**
75
- * The toolCallId Mastra is currently suspended on (after the natural
76
- * `tool-call-approval` chunk). `null` when no suspension. The
77
- * coordinator's pump loop reads this when the current `fullStream`
78
- * closes to decide whether to drive a resume.
82
+ * `toolCallId` of the MCP tool call currently in-flight on this turn,
83
+ * or `null` when no MCP tool is awaiting a result. Tracked at
84
+ * `routeEnriched` time: set when a MCP-namespaced `tool-call` arrives,
85
+ * cleared on the matching `tool-result`.
86
+ *
87
+ * Used by {@link emitMcpProgress} to attribute incoming
88
+ * `notifications/progress` (which only carry a `progressToken`) to the
89
+ * right `toolCallId` for the `tool-progress` ChatEvent.
90
+ *
91
+ * Mastra serializes MCP tool execution per workflow step, so at most
92
+ * one MCP tool call is in flight per coordinator at any time —
93
+ * matching the "currentMcpToolCallId" field's scalar shape.
94
+ */
95
+ currentMcpToolCallId = null;
96
+ /**
97
+ * Captures the (Mastra-namespaced) tool name for each `toolCallId`
98
+ * routed through `routeEnriched`. Used by {@link emitMcpProgress} to
99
+ * stamp `toolName` on the `tool-progress` ChatEvent so AG-UI Activity
100
+ * translators don't have to consult the originating `tool-call` event
101
+ * to route progress chunks.
102
+ */
103
+ toolNameForCallId = new Map();
104
+ /**
105
+ * ToolCallIds Mastra is currently suspended on. Mastra retains a separate
106
+ * workflow snapshot step per id, so the pump must drive one resume per
107
+ * pending id with `{ toolCallId }` to disambiguate (#586).
79
108
  */
80
- currentSuspendId = null;
109
+ pendingSuspendIds = new Set();
81
110
  /** Consumer-tool: bound parkers, keyed by `toolCallId`. `submitToolResult` resolves these. */
82
111
  pendingByToolCallId = new Map();
83
112
  /**
@@ -117,6 +146,43 @@ export class MastraApprovalCoordinator {
117
146
  initialSink() {
118
147
  return this.initial;
119
148
  }
149
+ /**
150
+ * Mastra's current run id. The harness's MCP-progress dispatcher uses
151
+ * this to filter incoming `notifications/progress` to the coordinator
152
+ * whose run produced the originating tool call (the upstream server
153
+ * received the progressToken Mastra injected from `context.runId`).
154
+ */
155
+ getCurrentRunId() {
156
+ return this.currentRunId;
157
+ }
158
+ /**
159
+ * Issue #591: dispatch an MCP `notifications/progress` notification
160
+ * received via `mcpClient.progress.onUpdate(...)` to the consumer's
161
+ * eventStream as a `tool-progress` ChatEvent.
162
+ *
163
+ * Caller (the harness MCP-progress listener) is responsible for
164
+ * filtering by `progressToken === coordinator.getCurrentRunId()` so this
165
+ * method only sees notifications belonging to its own turn.
166
+ *
167
+ * Drops the notification (no event surfaced) when no MCP tool call is
168
+ * currently in flight — the only path that produces `currentMcpToolCallId
169
+ * === null` after a turn started is "progress arrived after the matching
170
+ * tool-result." Losing a stragglier is preferable to surfacing a
171
+ * progress event without a meaningful toolCallId.
172
+ */
173
+ emitMcpProgress(params) {
174
+ if (this.aborted || this.disposed)
175
+ return;
176
+ const toolCallId = this.currentMcpToolCallId;
177
+ if (!toolCallId)
178
+ return;
179
+ this.initial.push({
180
+ type: 'tool-progress',
181
+ toolCallId,
182
+ toolName: this.toolNameForCallId.get(toolCallId) ?? 'unknown',
183
+ output: { ...params },
184
+ });
185
+ }
120
186
  onTeardown(callback) {
121
187
  if (this.aborted || this.disposed) {
122
188
  queueMicrotask(callback);
@@ -210,7 +276,7 @@ export class MastraApprovalCoordinator {
210
276
  this.initial.push({ type: 'finish', finishReason: 'error' });
211
277
  this.initial.end();
212
278
  }
213
- this.currentSuspendId = null;
279
+ this.pendingSuspendIds.clear();
214
280
  this.runTeardownCallbacks();
215
281
  }
216
282
  // ───────────────────────── settle methods ───────────────────────────────
@@ -233,14 +299,9 @@ export class MastraApprovalCoordinator {
233
299
  this.consumerSettled.add(toolCallId);
234
300
  this.cancelTimer(toolCallId);
235
301
  this.decisions.set(toolCallId, decision);
236
- // The pump loop is the single owner of `agent.approveToolCall(...)`
237
- // / `declineToolCall(...)` invocation. recordDecision buffers the
238
- // decision; the pump's outer loop reads `decisions.get(suspendId)`
239
- // when it sees the matching suspension and drives the resume. If
240
- // the pump is currently parked on `awaitContinuation` (saw the
241
- // suspension first, decision arrived second), wake it so it
242
- // re-checks `decisions`.
243
- if (this.continuationResolver && this.currentSuspendId === toolCallId) {
302
+ // Wake the pump if it's parked on `awaitContinuation` waiting for
303
+ // a decision on a pending id.
304
+ if (this.continuationResolver && this.pendingSuspendIds.has(toolCallId)) {
244
305
  const resolve = this.continuationResolver;
245
306
  this.continuationResolver = undefined;
246
307
  resolve('continued');
@@ -330,33 +391,29 @@ export class MastraApprovalCoordinator {
330
391
  }
331
392
  if (this.aborted || this.disposed || this.timedOut)
332
393
  return;
333
- // outcome === 'suspended': Mastra suspended after a
334
- // `tool-call-approval` chunk. `currentSuspendId` carries
335
- // the id Mastra is suspended on. Either the consumer
336
- // already settled (decision buffered) and we drive the
337
- // resume now, or we park until a settle arrives.
338
- const suspendId = this.currentSuspendId;
339
- if (suspendId === null) {
340
- // Defensive: stream ended without a suspension. Treat
341
- // as terminal.
394
+ // outcome === 'suspended': drive one resume per outer
395
+ // iteration. For parallel batches, all but the last resume
396
+ // returns a stream that closes silently while still
397
+ // suspended — the pump re-enters here to drive the next id.
398
+ if (this.pendingSuspendIds.size === 0) {
342
399
  if (!this.initial.isEnded())
343
400
  this.initial.end();
344
401
  return;
345
402
  }
346
- let buffered = this.decisions.get(suspendId);
347
- if (buffered === undefined) {
348
- // Park for a decision (or teardown).
403
+ let nextId = pickIdWithDecision(this.pendingSuspendIds, this.decisions);
404
+ if (nextId === undefined) {
349
405
  const cont = await this.awaitContinuation();
350
406
  if (cont === 'done')
351
407
  return;
352
- // Re-read `decisions` post-wake; recordDecision sets it
353
- // before signaling, so we must see it here.
354
- buffered = this.decisions.get(suspendId);
355
- if (buffered === undefined)
408
+ nextId = pickIdWithDecision(this.pendingSuspendIds, this.decisions);
409
+ if (nextId === undefined)
356
410
  return;
357
411
  }
358
- this.currentSuspendId = null;
359
- const next = await this.driveResumeReturning(suspendId, buffered);
412
+ const decision = this.decisions.get(nextId);
413
+ if (decision === undefined)
414
+ return;
415
+ this.pendingSuspendIds.delete(nextId);
416
+ const next = await this.driveResumeReturning(nextId, decision);
360
417
  if (next === 'done')
361
418
  return;
362
419
  currentStream = next;
@@ -434,22 +491,21 @@ export class MastraApprovalCoordinator {
434
491
  continue;
435
492
  }
436
493
  if (event.type === 'tool-approval-request') {
437
- // Mastra's natural `tool-call-approval` chunk → ChatEvent.
438
- // Mastra has suspended on this toolCallId. Decide whether
439
- // to surface a `tool-approval-request` to the consumer or
440
- // auto-approve silently.
494
+ // Parallel batches emit one chunk per suspension before
495
+ // the stream closes; accumulate every id and let the
496
+ // read loop continue to the natural close.
441
497
  const tc = event.toolCall;
442
498
  this.toolCallArgs.set(tc.toolCallId, tc.args);
443
- this.currentSuspendId = tc.toolCallId;
499
+ this.pendingSuspendIds.add(tc.toolCallId);
444
500
  sawSuspension = true;
445
501
  if (this.consumerToolNames.has(tc.toolName)) {
446
502
  // Consumer tools bypass the gate per the
447
503
  // StreamOptions.requireToolApproval JSDoc. Auto-
448
504
  // approve silently — no ChatEvent surfaces, and the
449
- // outer pump loop drives `agent.approveToolCall`
450
- // when it picks up the buffered decision. After
451
- // Mastra resumes, the bridge `execute` parker runs
452
- // and `submitToolResult` settles it — same shape as
505
+ // pump loop drives `agent.approveToolCall` when it
506
+ // picks up the buffered decision. After Mastra
507
+ // resumes, the bridge `execute` parker runs and
508
+ // `submitToolResult` settles it — same shape as
453
509
  // non-approval-mode consumer-tool flow.
454
510
  this.settledByCoordinator.add(tc.toolCallId);
455
511
  this.decisions.set(tc.toolCallId, { kind: 'approve' });
@@ -459,14 +515,7 @@ export class MastraApprovalCoordinator {
459
515
  // pre-emit in tool-call routing).
460
516
  this.emitApprovalRequest(tc.toolCallId, tc.toolName, tc.args);
461
517
  }
462
- // Drain the rest of the stream so Mastra's workflow
463
- // snapshot persists before we attempt a resume.
464
- while (true) {
465
- const { done: d } = await reader.read();
466
- if (d)
467
- break;
468
- }
469
- return 'suspended';
518
+ continue;
470
519
  }
471
520
  this.routeEnriched(event);
472
521
  }
@@ -490,17 +539,13 @@ export class MastraApprovalCoordinator {
490
539
  /* best-effort: lock may already be released after a thrown read */
491
540
  }
492
541
  }
493
- // Stream closed naturally without a `finish` chunk and without a
494
- // suspension. This happens when:
495
- // (a) abort/dispose/timeout fired and Mastra closed the stream
496
- // silently (no chunk emitted).
497
- // (b) consumer-tool flow: the tool's parker resolved, Mastra
498
- // continued the same fullStream, and the next assistant turn
499
- // ran to terminal `finish` — but in that case `finish` would
500
- // have arrived in the loop above. So (b) shouldn't reach here.
501
- // Treat as terminal: sink is either ended already (teardown) or
502
- // we must end it now.
503
- if (!sawTerminal && !sawSuspension && !this.initial.isEnded()) {
542
+ // Stream closed without `finish`/`error`. If any suspension is
543
+ // pending (just observed, or carried over from a prior non-final
544
+ // resume that closed silently), defer to the pump loop's drive.
545
+ if (sawSuspension || this.pendingSuspendIds.size > 0) {
546
+ return 'suspended';
547
+ }
548
+ if (!sawTerminal && !this.initial.isEnded()) {
504
549
  this.initial.end();
505
550
  }
506
551
  return 'terminal';
@@ -540,10 +585,10 @@ export class MastraApprovalCoordinator {
540
585
  return output.fullStream;
541
586
  }
542
587
  catch (err) {
588
+ // Route through teardown so siblings in `pendingSuspendIds`
589
+ // settle as auto-resolved rather than silently orphaning.
543
590
  const error = err instanceof Error ? err : new Error(String(err));
544
- this.initial.push({ type: 'error', error });
545
- this.initial.push({ type: 'finish', finishReason: 'error' });
546
- this.initial.end();
591
+ this.teardown('pump-error', error, 'pump-error');
547
592
  return 'done';
548
593
  }
549
594
  }
@@ -563,6 +608,21 @@ export class MastraApprovalCoordinator {
563
608
  if (this.aborted || this.disposed)
564
609
  return;
565
610
  const enriched = this.maybeEnrich(event);
611
+ // Issue #591: track in-flight MCP tool call so progress notifications
612
+ // arriving via `progress.onUpdate(...)` can be attributed to the
613
+ // right `toolCallId`. Set on enriched (i.e. MCP-namespaced) tool-call;
614
+ // cleared on the matching tool-result. Non-MCP tools don't carry
615
+ // `serverName` (the lookup returns undefined) so this never fires
616
+ // for built-ins, consumer-declared tools, or workspace tools.
617
+ if (enriched.type === 'tool-call' && enriched.serverName !== undefined) {
618
+ this.currentMcpToolCallId = enriched.toolCallId;
619
+ this.toolNameForCallId.set(enriched.toolCallId, enriched.toolName);
620
+ }
621
+ else if (enriched.type === 'tool-result' &&
622
+ enriched.serverName !== undefined &&
623
+ this.currentMcpToolCallId === enriched.toolCallId) {
624
+ this.currentMcpToolCallId = null;
625
+ }
566
626
  this.initial.push(enriched);
567
627
  }
568
628
  maybeEnrich(event) {
@@ -33,6 +33,8 @@ import type { ChatEvent } from '@salesforce/sfdx-agent-sdk';
33
33
  * | `tool-call-approval` | `tool-approval-request` | `{ toolCall: { toolCallId, toolName, args } }` — stream suspends |
34
34
  * | `tool-result` | `tool-result` | `{ toolCallId, toolName, result, isError: false }` |
35
35
  * | `tool-error` | `tool-result` | `{ toolCallId, toolName, result: error, isError: true}`|
36
+ * | `tool-output` | `tool-progress` | `{ toolCallId, toolName, output }` |
37
+ * | `tool-call-delta` | `tool-call-delta` | `{ toolCallId, toolName?, argsTextDelta }` |
36
38
  * | `step-start` | `step-start` | `{ stepIndex }` from step metadata |
37
39
  * | `step-finish` | `step-finish` | `{ stepIndex, finishReason, usage }` from `stepResult` |
38
40
  * | `finish` | `finish` | `{ finishReason, usage }` from `output` |
@@ -44,9 +46,8 @@ import type { ChatEvent } from '@salesforce/sfdx-agent-sdk';
44
46
  * | `reasoning-start`, | | |
45
47
  * | `reasoning-end` | | |
46
48
  * | `source`, `file`, | *(skipped)* | Known but unsupported — future ChatEvent candidates |
47
- * | `tool-call-delta`, | | |
48
49
  * | `tool-call-suspended`, | | |
49
- * | `object`, `tool-output` | | |
50
+ * | `object` | | |
50
51
  * | *(all others)* | *(skipped)* | Logged via `LogBus.debug` with `{ chunkType, rawChunk }` |
51
52
  *
52
53
  * Stateful per stream — a new instance must be created for each `MastraHarness.stream()` call.
@@ -34,6 +34,8 @@
34
34
  * | `tool-call-approval` | `tool-approval-request` | `{ toolCall: { toolCallId, toolName, args } }` — stream suspends |
35
35
  * | `tool-result` | `tool-result` | `{ toolCallId, toolName, result, isError: false }` |
36
36
  * | `tool-error` | `tool-result` | `{ toolCallId, toolName, result: error, isError: true}`|
37
+ * | `tool-output` | `tool-progress` | `{ toolCallId, toolName, output }` |
38
+ * | `tool-call-delta` | `tool-call-delta` | `{ toolCallId, toolName?, argsTextDelta }` |
37
39
  * | `step-start` | `step-start` | `{ stepIndex }` from step metadata |
38
40
  * | `step-finish` | `step-finish` | `{ stepIndex, finishReason, usage }` from `stepResult` |
39
41
  * | `finish` | `finish` | `{ finishReason, usage }` from `output` |
@@ -45,9 +47,8 @@
45
47
  * | `reasoning-start`, | | |
46
48
  * | `reasoning-end` | | |
47
49
  * | `source`, `file`, | *(skipped)* | Known but unsupported — future ChatEvent candidates |
48
- * | `tool-call-delta`, | | |
49
50
  * | `tool-call-suspended`, | | |
50
- * | `object`, `tool-output` | | |
51
+ * | `object` | | |
51
52
  * | *(all others)* | *(skipped)* | Logged via `LogBus.debug` with `{ chunkType, rawChunk }` |
52
53
  *
53
54
  * Stateful per stream — a new instance must be created for each `MastraHarness.stream()` call.
@@ -169,19 +170,35 @@ export class MastraEventAdapter {
169
170
  case 'reasoning-end':
170
171
  this.logBus?.debug('Skipping informational Mastra chunk', { chunkType: chunk.type });
171
172
  return undefined;
173
+ case 'tool-output':
174
+ return {
175
+ type: 'tool-progress',
176
+ toolCallId: chunk.payload.toolCallId,
177
+ toolName: chunk.payload.toolName ?? 'unknown',
178
+ output: chunk.payload.output,
179
+ };
180
+ case 'tool-call-delta':
181
+ return {
182
+ type: 'tool-call-delta',
183
+ toolCallId: chunk.payload.toolCallId,
184
+ // Strict `!== undefined` (not truthy) so an empty-string
185
+ // toolName from upstream — unlikely but possible — is
186
+ // forwarded honestly instead of silently swallowed.
187
+ ...(chunk.payload.toolName !== undefined ? { toolName: chunk.payload.toolName } : {}),
188
+ argsTextDelta: chunk.payload.argsTextDelta,
189
+ };
172
190
  // Known harness chunk types not yet supported in the SDK's ChatEvent
173
191
  // model. Silently dropped to avoid leaking harness internals onto the
174
192
  // public stream. Candidates for future ChatEvent variants when the
175
- // corresponding features (citations, file generation, tool arg
176
- // streaming, tool suspension, structured output) are supported.
193
+ // corresponding features (citations, file generation, tool input
194
+ // lifecycle bookends, tool suspension, structured output) are
195
+ // supported.
177
196
  case 'source':
178
197
  case 'file':
179
- case 'tool-call-delta':
180
198
  case 'tool-call-input-streaming-start':
181
199
  case 'tool-call-input-streaming-end':
182
200
  case 'tool-call-suspended':
183
201
  case 'object':
184
- case 'tool-output':
185
202
  this.logBus?.debug('Skipping unsupported Mastra chunk type', { chunkType: chunk.type });
186
203
  return undefined;
187
204
  default:
@@ -478,6 +478,22 @@ export declare class MastraHarness implements MastraAgentHarness {
478
478
  * after `shutdown()` has disposed the buses.
479
479
  */
480
480
  private prepareMcpStatusChange;
481
+ /**
482
+ * Issue #591: install a `progress.onUpdate(serverName, ...)` listener
483
+ * on the MCP client. Incoming `notifications/progress` are dispatched
484
+ * to the active coordinator on this agent whose `currentRunId` matches
485
+ * the notification's `progressToken` — `enableProgressTracking: true`
486
+ * makes `@mastra/mcp` inject `_meta.progressToken = context.runId` into
487
+ * every tool call, so the token uniquely identifies the originating
488
+ * turn even across concurrent threads on the same agent.
489
+ *
490
+ * `progress.onUpdate` is async (it lazily connects the underlying
491
+ * internal client). We swallow rejections inside a fire-and-forget
492
+ * wrapper rather than blocking `createAgent`/`updateAgent` — discovery
493
+ * and progress tracking are independent: a server that fails to wire
494
+ * progress still works for tool calls, just without progress events.
495
+ */
496
+ private registerMcpProgressListener;
481
497
  /**
482
498
  * Runs MCP discovery for one server: emits the
483
499
  * `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
@@ -473,6 +473,9 @@ export class MastraHarness {
473
473
  };
474
474
  mcpServerStates.set(serverName, entry);
475
475
  entry.ready = this.runMcpDiscovery(agentId, serverName, client, mcpServerStates, 0, options?.abortSignal);
476
+ // Issue #591: bridge MCP `notifications/progress` onto the
477
+ // coordinator's per-turn sink as `tool-progress` ChatEvents.
478
+ this.registerMcpProgressListener(agentId, serverName, client);
476
479
  }
477
480
  }
478
481
  // Issue #529 step 2c: the bridge `execute` handler resolves the
@@ -893,6 +896,8 @@ export class MastraHarness {
893
896
  };
894
897
  state.mcpServers.set(name, entry);
895
898
  entry.ready = this.runMcpDiscovery(agentId, name, client, state.mcpServers, 0, options?.abortSignal);
899
+ // Issue #591: same MCP-progress wiring as createAgent path.
900
+ this.registerMcpProgressListener(agentId, name, client);
896
901
  }
897
902
  }
898
903
  }
@@ -1528,6 +1533,52 @@ export class MastraHarness {
1528
1533
  }
1529
1534
  };
1530
1535
  }
1536
+ /**
1537
+ * Issue #591: install a `progress.onUpdate(serverName, ...)` listener
1538
+ * on the MCP client. Incoming `notifications/progress` are dispatched
1539
+ * to the active coordinator on this agent whose `currentRunId` matches
1540
+ * the notification's `progressToken` — `enableProgressTracking: true`
1541
+ * makes `@mastra/mcp` inject `_meta.progressToken = context.runId` into
1542
+ * every tool call, so the token uniquely identifies the originating
1543
+ * turn even across concurrent threads on the same agent.
1544
+ *
1545
+ * `progress.onUpdate` is async (it lazily connects the underlying
1546
+ * internal client). We swallow rejections inside a fire-and-forget
1547
+ * wrapper rather than blocking `createAgent`/`updateAgent` — discovery
1548
+ * and progress tracking are independent: a server that fails to wire
1549
+ * progress still works for tool calls, just without progress events.
1550
+ */
1551
+ registerMcpProgressListener(agentId, serverName, client) {
1552
+ client.progress
1553
+ .onUpdate(serverName, (params) => {
1554
+ const agentState = this.activeAgents.get(agentId);
1555
+ if (!agentState)
1556
+ return;
1557
+ // Walk per-thread coordinators to find the one whose current
1558
+ // run produced this progressToken. Mastra's
1559
+ // `enableProgressTracking` injects `progressToken =
1560
+ // context.runId`, so `progressToken === coordinator.runId`
1561
+ // is an exact, single-match filter.
1562
+ const targetToken = String(params.progressToken);
1563
+ for (const coordinator of agentState.coordinators.values()) {
1564
+ if (coordinator.getCurrentRunId() === targetToken) {
1565
+ coordinator.emitMcpProgress({
1566
+ progress: params.progress,
1567
+ ...(params.total !== undefined && { total: params.total }),
1568
+ ...(params.message !== undefined && { message: params.message }),
1569
+ });
1570
+ return;
1571
+ }
1572
+ }
1573
+ })
1574
+ .catch((err) => {
1575
+ this.buses.getLogBus()?.warn('Failed to register MCP progress listener', {
1576
+ agentId,
1577
+ serverName,
1578
+ error: getErrorMessage(err),
1579
+ });
1580
+ });
1581
+ }
1531
1582
  /**
1532
1583
  * Runs MCP discovery for one server: emits the
1533
1584
  * `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
@@ -56,6 +56,13 @@ export function mapToMastraMCPServers(config, orgJwt) {
56
56
  ...(serverConfig.args !== undefined && { args: serverConfig.args }),
57
57
  ...(serverConfig.env !== undefined && { env: serverConfig.env }),
58
58
  ...(serverConfig.timeout !== undefined && { timeout: serverConfig.timeout }),
59
+ // Issue #591: opt every server in to MCP progress notifications
60
+ // so the upstream server knows it can send `notifications/progress`
61
+ // (the harness bridges those to `tool-progress` ChatEvents via
62
+ // `mcpClient.progress.onUpdate(...)`). Default in `@mastra/mcp` is
63
+ // off, so without this the spec-compliant progressToken is never
64
+ // injected into `_meta` and servers send no progress.
65
+ enableProgressTracking: true,
59
66
  };
60
67
  result[name] = def;
61
68
  }
@@ -77,6 +84,8 @@ export function mapToMastraMCPServers(config, orgJwt) {
77
84
  ...serverConfig.reconnectionOptions,
78
85
  },
79
86
  }),
87
+ // Issue #591: same rationale as stdio.
88
+ enableProgressTracking: true,
80
89
  };
81
90
  result[name] = def;
82
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -38,11 +38,11 @@
38
38
  "@salesforce/llm-gateway-sdk": "0.14.0"
39
39
  },
40
40
  "peerDependencies": {
41
- "@salesforce/sfdx-agent-sdk": "0.18.0"
41
+ "@salesforce/sfdx-agent-sdk": "0.20.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@eslint/js": "^10.0.1",
45
- "@salesforce/sfdx-agent-sdk": "0.18.0",
45
+ "@salesforce/sfdx-agent-sdk": "0.20.0",
46
46
  "@types/node": "^22.19.20",
47
47
  "@vitest/coverage-istanbul": "^4.1.8",
48
48
  "@vitest/eslint-plugin": "^1.6.19",