@salesforce/sfdx-agent-harness-mastra 0.26.0 → 0.27.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.27.0] - 2026-06-30
7
+
8
+ ### Features
9
+ - **sdk,harness-mastra,harness-claude**: add bareToolName to MCP tool events for remember matcher @W-23090030 ([#634](https://github.com/forcedotcom/agentic-dx/pull/634))
10
+
11
+ ### Fixes
12
+ - **harness-mastra**: recover discarded remote-MCP HTTP error body so consumers can classify org_not_enabled @W-23231658 ([#633](https://github.com/forcedotcom/agentic-dx/pull/633))
13
+
14
+ ### Chores
15
+ - **deps**: bump the mastra group across 1 directory with 4 updates ([#630](https://github.com/forcedotcom/agentic-dx/pull/630))
16
+ - **deps**: bump @anthropic-ai/sdk from 0.105.0 to 0.106.0 ([#632](https://github.com/forcedotcom/agentic-dx/pull/632))
17
+ - **deps-dev**: bump the eslint group across 1 directory with 2 updates ([#629](https://github.com/forcedotcom/agentic-dx/pull/629))
18
+ - **deps-dev**: bump prettier from 3.8.4 to 3.9.0 in the dev-dependencies group ([#628](https://github.com/forcedotcom/agentic-dx/pull/628))
19
+
6
20
  ## [0.26.0] - 2026-06-29
7
21
 
8
22
  _No changes — released alongside dependent packages._
@@ -759,11 +759,16 @@ export class MastraApprovalCoordinator {
759
759
  const invocation = this.buildToolInvocation(toolName);
760
760
  // No serverName ⇒ built-in / consumer tool ⇒ nothing to stamp. The
761
761
  // event keeps its Mastra-namespaced display `toolName`; enrichment only
762
- // adds `serverName` / `annotations`.
762
+ // adds `serverName` / `annotations` / `bareToolName`.
763
763
  if (invocation.serverName === undefined)
764
764
  return event;
765
+ // The display `toolName` stays Mastra-namespaced (`${server}_${tool}`);
766
+ // `bareToolName` carries the un-namespaced leaf so the SDK can build a
767
+ // matching `mcp` policy matcher (the AG-UI "Allow always" / remember flow)
768
+ // without string-splitting the display name.
765
769
  const fields = {
766
770
  serverName: invocation.serverName,
771
+ bareToolName: invocation.toolName,
767
772
  ...(invocation.annotations ? { annotations: invocation.annotations } : {}),
768
773
  };
769
774
  if (event.type === 'tool-call') {
@@ -4,6 +4,7 @@ import { MCPClient } from '@mastra/mcp';
4
4
  import { McpServerStatus, type AgentHarness, type WithAgentConfig, type ChatStreamResult, type HarnessAgentConfig, type MCPConfiguration, type McpServerErrorDetail, type McpServerInfo, type Message, type MessagePart, type ModelConnectivityInfo, type StreamOptions, type TelemetryEventCallback, type ToolResultInfo, type WireCommunicationEventCallback } from '@salesforce/sfdx-agent-sdk';
5
5
  import { type AgentHooks } from '@salesforce/sfdx-agent-sdk/harness';
6
6
  import { MastraApprovalCoordinator } from './mastra-approval-coordinator.js';
7
+ import { type HttpErrorCapture } from './mcp-config-mapper.js';
7
8
  import { type MastraAgentConfig, type SkillSearchHandle, type ToolSearchHandle } from './tool-search.js';
8
9
  import { type MultimodalFile } from '@salesforce/sfdx-agent-sdk';
9
10
  import { Clock, type LogRecord, type UniqueIDGenerator, type Unsubscribe } from '@salesforce/agentic-common';
@@ -51,6 +52,15 @@ interface McpServerState {
51
52
  * on (tool merging, alwaysActive expansion, MCP-tool metadata lookup).
52
53
  */
53
54
  tools: ToolsInput;
55
+ /**
56
+ * HTTP-error capture slot for remote servers (W-23231658). The remote
57
+ * transport fetch (built by `mapToMastraMCPServers`) writes the latest
58
+ * non-2xx `{ status, body }` here; the discovery / reconnect catch reads
59
+ * `latest` to recover the HTTP status `@mastra/mcp` discards when a
60
+ * fallback-eligible connect fails. `undefined` for stdio servers and for
61
+ * remote servers when no capture map was threaded in.
62
+ */
63
+ httpErrorCapture?: HttpErrorCapture;
54
64
  }
55
65
  /**
56
66
  * Internal state for a single agent. Holds Mastra-specific instances so the
@@ -20,7 +20,7 @@ import { mapMastraToMessage, mapMessageToMastra, partToTranscriptText } from './
20
20
  import { isStandardSchemaWithJSON, standardSchemaToJSONSchema } from '@mastra/schema-compat';
21
21
  import { mapToolDefinitionsToMastraTools } from './mastra-tool-mapper.js';
22
22
  import { hasEnabledServers, mapToMastraMCPServers } from './mcp-config-mapper.js';
23
- import { buildMcpErrorDetail, sanitizeMcpErrorMessage } from './mcp-error-classifier.js';
23
+ import { buildMcpErrorDetail, sanitizeMcpErrorMessage, synthesizeHttpTransportError } from './mcp-error-classifier.js';
24
24
  import { partitionPool, resolveAlwaysActive, } from './tool-search.js';
25
25
  import { validateMultimodalFiles } from '@salesforce/sfdx-agent-sdk';
26
26
  import { buildAnthropicDirectLanguageModel, buildAnthropicLanguageModel } from './pass-through-anthropic.js';
@@ -490,7 +490,11 @@ export class MastraHarness {
490
490
  const mcpServerStates = new Map();
491
491
  if (hasEnabledServers(config.mcpServers)) {
492
492
  options?.abortSignal?.throwIfAborted();
493
- const enabledServers = mapToMastraMCPServers(config.mcpServers, config.orgJwt, this.innerFetch);
493
+ // Empty capture map; the mapper creates a per-remote-server capture
494
+ // object and stores it here so we can attach each to its
495
+ // McpServerState below (W-23231658).
496
+ const httpErrorCaptures = new Map();
497
+ const enabledServers = mapToMastraMCPServers(config.mcpServers, config.orgJwt, this.innerFetch, httpErrorCaptures);
494
498
  for (const [serverName, serverDef] of Object.entries(enabledServers)) {
495
499
  const client = new MCPClient({
496
500
  id: `${agentId}:${serverName}`,
@@ -508,6 +512,7 @@ export class MastraHarness {
508
512
  ready: Promise.resolve(),
509
513
  settled: false,
510
514
  tools: {},
515
+ httpErrorCapture: httpErrorCaptures.get(serverName),
511
516
  };
512
517
  mcpServerStates.set(serverName, entry);
513
518
  entry.ready = this.runMcpDiscovery(agentId, serverName, client, mcpServerStates, 0, options?.abortSignal);
@@ -815,6 +820,11 @@ export class MastraHarness {
815
820
  agentId,
816
821
  serverName,
817
822
  });
823
+ // W-23231658: clear any stale HTTP-error capture so a prior failure's
824
+ // status can't be mistaken for this reconnect's. The wrapper repopulates
825
+ // `latest` only if this reconnect's connect sees a non-2xx response.
826
+ if (serverState.httpErrorCapture)
827
+ serverState.httpErrorCapture.latest = undefined;
818
828
  // Transport-level reconnect can throw (stdio spawn failure, remote handshake
819
829
  // failure). Record on serverState and emit only `failed` (not started+failed
820
830
  // — `started` already fired above). This keeps the public contract
@@ -826,8 +836,13 @@ export class MastraHarness {
826
836
  // Drop telemetry + state writes if a newer epoch has superseded us.
827
837
  if (state.mcpServers.get(serverName)?.epoch !== epoch)
828
838
  return;
829
- const message = sanitizeMcpErrorMessage(err);
830
- const errorDetail = buildMcpErrorDetail(err);
839
+ // Prefer the captured HTTP status + body over the bodyless generic
840
+ // error `@mastra/mcp` rethrows on a fallback-eligible connect
841
+ // failure (W-23231658); same recovery as the discovery catch.
842
+ const captured = serverState.httpErrorCapture?.latest;
843
+ const effectiveError = captured ? synthesizeHttpTransportError(captured.status, captured.body, err) : err;
844
+ const message = sanitizeMcpErrorMessage(effectiveError);
845
+ const errorDetail = buildMcpErrorDetail(effectiveError);
831
846
  serverState.error = message;
832
847
  serverState.errorDetail = errorDetail;
833
848
  serverState.settled = true;
@@ -844,7 +859,7 @@ export class MastraHarness {
844
859
  agentId,
845
860
  serverName,
846
861
  durationMs: this.clock.now().getTime() - startTime,
847
- error: err instanceof Error ? err : new Error(message),
862
+ error: effectiveError instanceof Error ? effectiveError : new Error(message),
848
863
  errorDetail,
849
864
  });
850
865
  }
@@ -932,8 +947,9 @@ export class MastraHarness {
932
947
  const prevConfig = state.mcpConfig ?? {};
933
948
  const nextConfig = config.mcpServers ?? {};
934
949
  const allNames = new Set([...Object.keys(prevConfig), ...Object.keys(nextConfig)]);
950
+ const nextHttpErrorCaptures = new Map();
935
951
  const nextEnabledServers = hasEnabledServers(nextConfig)
936
- ? mapToMastraMCPServers(nextConfig, config.orgJwt, this.innerFetch)
952
+ ? mapToMastraMCPServers(nextConfig, config.orgJwt, this.innerFetch, nextHttpErrorCaptures)
937
953
  : {};
938
954
  for (const name of allNames) {
939
955
  const prev = prevConfig[name];
@@ -965,6 +981,7 @@ export class MastraHarness {
965
981
  ready: Promise.resolve(),
966
982
  settled: false,
967
983
  tools: {},
984
+ httpErrorCapture: nextHttpErrorCaptures.get(name),
968
985
  };
969
986
  state.mcpServers.set(name, entry);
970
987
  entry.ready = this.runMcpDiscovery(agentId, name, client, state.mcpServers, 0, options?.abortSignal);
@@ -1824,6 +1841,13 @@ export class MastraHarness {
1824
1841
  const s = serverStates.get(serverName);
1825
1842
  return s && s.epoch === epoch ? s : undefined;
1826
1843
  };
1844
+ // W-23231658: clear any HTTP-error capture left from a prior discovery
1845
+ // run on this (reused) client/capture so a stale status can't bleed into
1846
+ // this run's failure classification. The wrapper repopulates `latest`
1847
+ // only if a non-2xx response actually occurs during this connect.
1848
+ const captureSlot = serverStates.get(serverName)?.httpErrorCapture;
1849
+ if (captureSlot)
1850
+ captureSlot.latest = undefined;
1827
1851
  return makeAbortable(client.listToolsetsWithErrors(), abortSignal)
1828
1852
  .then(({ toolsets, errors }) => {
1829
1853
  const serverError = errors[serverName];
@@ -1877,8 +1901,20 @@ export class MastraHarness {
1877
1901
  // Flip `settled` AND `currentStatus` BEFORE emitting any
1878
1902
  // terminal telemetry — see the matching comment in the
1879
1903
  // success path above (issue #373).
1880
- const errorMessage = sanitizeMcpErrorMessage(err);
1881
- const errorDetail = buildMcpErrorDetail(err);
1904
+ //
1905
+ // W-23231658: when the remote transport captured a non-2xx
1906
+ // HTTP response, prefer it over the bodyless generic error
1907
+ // `@mastra/mcp` rethrows. The capture is only ever populated
1908
+ // when a real HTTP error occurred on this server's transport,
1909
+ // so by construction it IS the (or a) cause of this failure;
1910
+ // the original `err` rides along as `cause` so its stack is
1911
+ // preserved on the failed event.
1912
+ const captured = s.httpErrorCapture?.latest;
1913
+ const effectiveError = captured
1914
+ ? synthesizeHttpTransportError(captured.status, captured.body, err)
1915
+ : err;
1916
+ const errorMessage = sanitizeMcpErrorMessage(effectiveError);
1917
+ const errorDetail = buildMcpErrorDetail(effectiveError);
1882
1918
  s.error = errorMessage;
1883
1919
  s.errorDetail = errorDetail;
1884
1920
  s.settled = true;
@@ -1895,7 +1931,7 @@ export class MastraHarness {
1895
1931
  agentId,
1896
1932
  serverName,
1897
1933
  durationMs: this.clock.now().getTime() - startTime,
1898
- error: err instanceof Error ? err : new Error(String(err)),
1934
+ error: effectiveError instanceof Error ? effectiveError : new Error(String(effectiveError)),
1899
1935
  errorDetail,
1900
1936
  });
1901
1937
  }
@@ -6,6 +6,23 @@ import { type MCPConfiguration } from '@salesforce/sfdx-agent-sdk';
6
6
  * A server is considered enabled when its `enabled` property is `true` or absent.
7
7
  */
8
8
  export declare function hasEnabledServers(config: MCPConfiguration | undefined): config is MCPConfiguration;
9
+ /** A captured non-2xx HTTP response from a remote MCP transport request. */
10
+ export type CapturedHttpError = {
11
+ status: number;
12
+ body: string;
13
+ };
14
+ /**
15
+ * Per-server mutable slot the remote-MCP fetch wrapper writes the latest
16
+ * non-2xx response into. The harness reads `latest` in its discovery /
17
+ * reconnect catch blocks to recover the HTTP status + body that
18
+ * `@mastra/mcp`'s `connectHttp` discards when a fallback-eligible status
19
+ * (`400` / `404` / `405`) triggers the SSE fallback and both transports fail —
20
+ * the thrown `Error("Could not connect to server with any available HTTP
21
+ * transport")` carries no status, no body, and no `cause` (W-23231658, #617).
22
+ */
23
+ export type HttpErrorCapture = {
24
+ latest?: CapturedHttpError;
25
+ };
9
26
  /**
10
27
  * Converts our {@link MCPConfiguration} into the `servers` map expected by Mastra's `MCPClient`.
11
28
  *
@@ -31,4 +48,13 @@ export declare function mapToMastraMCPServers(config: MCPConfiguration, orgJwt?:
31
48
  * here so MCP remote transports honor `HTTPS_PROXY` without `globalThis`
32
49
  * mutation. Stdio transports ignore it (they're child processes).
33
50
  */
34
- innerFetch?: typeof fetch): Record<string, MastraMCPServerDefinition>;
51
+ innerFetch?: typeof fetch,
52
+ /**
53
+ * Optional per-server HTTP-error capture map. When supplied, every remote
54
+ * server's transport fetch is wrapped so the latest non-2xx `{ status, body }`
55
+ * is recorded into `captures.get(serverName)`. The harness reads this in its
56
+ * discovery / reconnect catch to recover the status `@mastra/mcp` discards
57
+ * (W-23231658). Stdio servers contribute no entry. Omitted in call sites
58
+ * that don't need recovery (e.g. unit tests of the mapping shape).
59
+ */
60
+ captures?: Map<string, HttpErrorCapture>): Record<string, MastraMCPServerDefinition>;
@@ -12,6 +12,13 @@ export function hasEnabledServers(config) {
12
12
  return false;
13
13
  return Object.values(config).some((s) => s.enabled !== false);
14
14
  }
15
+ /**
16
+ * Cap on the captured error-body text. A connect-error body is a small JSON
17
+ * envelope (e.g. `{"error":{"code":404,"message":"..."}}`); the slice guards
18
+ * against an unexpectedly large body bloating the `McpServerInfo.error` string
19
+ * and telemetry payloads.
20
+ */
21
+ const MAX_CAPTURED_BODY_CHARS = 8192;
15
22
  /**
16
23
  * MCP SDK's built-in `StreamableHTTPReconnectionOptions` defaults. Mirrored
17
24
  * here so partial `MCPRemoteServerConfig.reconnectionOptions` inputs merge
@@ -51,7 +58,16 @@ export function mapToMastraMCPServers(config, orgJwt,
51
58
  * here so MCP remote transports honor `HTTPS_PROXY` without `globalThis`
52
59
  * mutation. Stdio transports ignore it (they're child processes).
53
60
  */
54
- innerFetch = globalThis.fetch) {
61
+ innerFetch = globalThis.fetch,
62
+ /**
63
+ * Optional per-server HTTP-error capture map. When supplied, every remote
64
+ * server's transport fetch is wrapped so the latest non-2xx `{ status, body }`
65
+ * is recorded into `captures.get(serverName)`. The harness reads this in its
66
+ * discovery / reconnect catch to recover the status `@mastra/mcp` discards
67
+ * (W-23231658). Stdio servers contribute no entry. Omitted in call sites
68
+ * that don't need recovery (e.g. unit tests of the mapping shape).
69
+ */
70
+ captures) {
55
71
  const result = {};
56
72
  for (const [name, serverConfig] of Object.entries(config)) {
57
73
  if (serverConfig.enabled === false) {
@@ -75,12 +91,34 @@ innerFetch = globalThis.fetch) {
75
91
  }
76
92
  else if (serverConfig.type === 'remote') {
77
93
  const url = serverConfig.url instanceof URL ? serverConfig.url : new URL(serverConfig.url);
94
+ // Base transport fetch: auth-injecting when an orgJwt is present,
95
+ // otherwise the bare inner (proxy-aware) fetch.
96
+ const baseFetch = orgJwt
97
+ ? createAuthFetch(serverConfig.url, orgJwt, innerFetch, serverConfig.headers)
98
+ : innerFetch;
99
+ // Wrap with HTTP-error capture when the harness asked for it, so a
100
+ // non-2xx connect response's status + body survive the error
101
+ // `@mastra/mcp` discards (W-23231658). The mapper owns the capture
102
+ // object's creation and stores it on the supplied map keyed by
103
+ // server name so the caller can read `latest` back from the matching
104
+ // `McpServerState`. When no capture map is supplied (e.g.
105
+ // mapping-shape unit tests) and there's no orgJwt, the def omits
106
+ // `fetch` entirely — preserving prior behavior.
107
+ let transportFetch;
108
+ if (captures) {
109
+ const capture = {};
110
+ captures.set(name, capture);
111
+ transportFetch = wrapFetchWithErrorCapture(baseFetch, capture);
112
+ }
113
+ else {
114
+ transportFetch = orgJwt ? baseFetch : undefined;
115
+ }
78
116
  const def = {
79
117
  url,
80
118
  ...(serverConfig.headers !== undefined && {
81
119
  requestInit: { headers: serverConfig.headers },
82
120
  }),
83
- ...(orgJwt && { fetch: createAuthFetch(serverConfig.url, orgJwt, innerFetch, serverConfig.headers) }),
121
+ ...(transportFetch && { fetch: transportFetch }),
84
122
  ...(serverConfig.timeout !== undefined && {
85
123
  timeout: serverConfig.timeout,
86
124
  connectTimeout: serverConfig.timeout,
@@ -116,4 +154,34 @@ function createAuthFetch(serverUrl, orgJwt, innerFetch, existingHeaders) {
116
154
  return innerFetch(url, { ...init, headers: merged });
117
155
  };
118
156
  }
157
+ /**
158
+ * Wrap a transport fetch so every non-2xx response's status + body is recorded
159
+ * into `capture.latest` before the response is handed back to `@mastra/mcp`.
160
+ * This is the only point where the connect-error body still exists: when the
161
+ * Streamable-HTTP transport sees a fallback-eligible status it throws a
162
+ * `StreamableHTTPError(status, ...)`, but `connectHttp` then discards it on the
163
+ * SSE-fallback path and the harness only ever sees the bodyless generic
164
+ * `Error("Could not connect...")` (W-23231658, #617).
165
+ *
166
+ * The body is read from a clone so the original response stream stays intact for
167
+ * the transport. A clone-read failure (already-consumed body, network hiccup) is
168
+ * swallowed — capture is best-effort and must never perturb the real request.
169
+ */
170
+ function wrapFetchWithErrorCapture(baseFetch, capture) {
171
+ return async (input, init) => {
172
+ const response = await baseFetch(input, init);
173
+ if (!response.ok) {
174
+ try {
175
+ const body = await response.clone().text();
176
+ capture.latest = { status: response.status, body: body.slice(0, MAX_CAPTURED_BODY_CHARS) };
177
+ }
178
+ catch {
179
+ // Body unreadable (already consumed / stream error). Record the
180
+ // status alone so classification still has the HTTP code.
181
+ capture.latest = { status: response.status, body: '' };
182
+ }
183
+ }
184
+ return response;
185
+ };
186
+ }
119
187
  //# sourceMappingURL=mcp-config-mapper.js.map
@@ -18,13 +18,34 @@ export declare function sanitizeMcpErrorMessage(err: unknown): string;
18
18
  * 1. `DOMException` `AbortError` and Node's `code === 'ABORT_ERR'` map to
19
19
  * `'aborted'` regardless of the `.message`.
20
20
  * 2. MCP-shaped errors (an `Error` carrying a numeric `code`, see
21
- * {@link isMcpErrorLike}) match by JSON-RPC 2.0 code (`-32001` etc.).
21
+ * {@link isMcpErrorLike}) match by code: a positive code in `[100, 599]` is
22
+ * an HTTP status (see {@link categoryForHttpStatus}); a negative code is a
23
+ * JSON-RPC 2.0 error (`-32001` etc.).
22
24
  * 3. Free-form `.message` text is matched against substrings the underlying
23
25
  * transports surface (`401`, `403`, `5xx`, `ECONNREFUSED`, etc.). This is
24
26
  * intentionally a small lookup — categories are stable, message shapes
25
27
  * are not, so the heuristic stays narrow and falls back to `'unknown'`.
26
28
  */
27
29
  export declare function classifyMcpError(err: unknown): McpServerErrorCategory;
30
+ /**
31
+ * Synthesize an MCP-shaped error (`Error & { code: number }`) from a captured
32
+ * non-2xx HTTP transport response. `@mastra/mcp`'s `connectHttp` discards the
33
+ * underlying `StreamableHTTPError` (which carries the HTTP status on `code`)
34
+ * when the SSE-fallback path also fails, throwing a bodyless generic error
35
+ * instead. The harness's remote-transport fetch wrapper records the
36
+ * `{ status, body }` it saw; this rebuilds the shape `classifyMcpError` /
37
+ * {@link buildMcpErrorDetail} need so the status survives to `McpServerInfo`
38
+ * and the `mcp-server-discovery-failed` event (W-23231658, #617).
39
+ *
40
+ * The resulting message mirrors the MCP SDK's own `StreamableHTTPError` text
41
+ * (`Streamable HTTP error: ...`) so message-substring consumers see a familiar
42
+ * shape, and `code` carries the HTTP status so the layered classifier routes it
43
+ * to `http-4xx` / `http-5xx` / `http-401` / `http-403`. The original generic
44
+ * error is attached as `cause` so its stack is preserved on the failed event.
45
+ */
46
+ export declare function synthesizeHttpTransportError(status: number, body: string, cause?: unknown): Error & {
47
+ code: number;
48
+ };
28
49
  /**
29
50
  * Build the SDK-side {@link McpServerErrorDetail} from a caught error. The
30
51
  * underlying JSON-RPC `code` (when the error is MCP-shaped, see
@@ -47,6 +47,27 @@ export function sanitizeMcpErrorMessage(err) {
47
47
  // All lines were stack frames — fall back to the raw single-line input.
48
48
  return lines[0]?.trim() ?? '';
49
49
  }
50
+ /**
51
+ * Map an HTTP status code to the SDK's stable {@link McpServerErrorCategory}.
52
+ * HTTP status codes are positive (100–599); JSON-RPC error codes are negative,
53
+ * so the two never collide on the numeric `code` field. The MCP SDK's
54
+ * `StreamableHTTPError` carries the HTTP status verbatim on `code`
55
+ * (`@modelcontextprotocol/sdk` `streamableHttp.js`), and the harness synthesizes
56
+ * the same shape from a captured remote-transport error body (W-23231658), so a
57
+ * 404 "org not enabled" surfaces as `http-4xx` + `code: 404` rather than
58
+ * collapsing to `unknown`.
59
+ */
60
+ function categoryForHttpStatus(status) {
61
+ if (status === 401)
62
+ return 'http-401';
63
+ if (status === 403)
64
+ return 'http-403';
65
+ if (status >= 400 && status <= 499)
66
+ return 'http-4xx';
67
+ if (status >= 500 && status <= 599)
68
+ return 'http-5xx';
69
+ return 'unknown';
70
+ }
50
71
  /**
51
72
  * Map a caught error to the SDK's stable {@link McpServerErrorCategory}. The
52
73
  * lookup is layered:
@@ -54,7 +75,9 @@ export function sanitizeMcpErrorMessage(err) {
54
75
  * 1. `DOMException` `AbortError` and Node's `code === 'ABORT_ERR'` map to
55
76
  * `'aborted'` regardless of the `.message`.
56
77
  * 2. MCP-shaped errors (an `Error` carrying a numeric `code`, see
57
- * {@link isMcpErrorLike}) match by JSON-RPC 2.0 code (`-32001` etc.).
78
+ * {@link isMcpErrorLike}) match by code: a positive code in `[100, 599]` is
79
+ * an HTTP status (see {@link categoryForHttpStatus}); a negative code is a
80
+ * JSON-RPC 2.0 error (`-32001` etc.).
58
81
  * 3. Free-form `.message` text is matched against substrings the underlying
59
82
  * transports surface (`401`, `403`, `5xx`, `ECONNREFUSED`, etc.). This is
60
83
  * intentionally a small lookup — categories are stable, message shapes
@@ -64,6 +87,12 @@ export function classifyMcpError(err) {
64
87
  if (isAbortError(err))
65
88
  return 'aborted';
66
89
  if (isMcpErrorLike(err)) {
90
+ // HTTP status (positive, 100–599) before the JSON-RPC switch — the two
91
+ // code spaces are disjoint (JSON-RPC codes are negative), so an HTTP
92
+ // 404 no longer falls through the JSON-RPC `default` to `unknown`.
93
+ if (err.code >= 100 && err.code <= 599) {
94
+ return categoryForHttpStatus(err.code);
95
+ }
67
96
  switch (err.code) {
68
97
  case -32001: // RequestTimeout
69
98
  return 'connect-timeout';
@@ -111,6 +140,28 @@ function isCategoryRetriable(category) {
111
140
  return true;
112
141
  }
113
142
  }
143
+ /**
144
+ * Synthesize an MCP-shaped error (`Error & { code: number }`) from a captured
145
+ * non-2xx HTTP transport response. `@mastra/mcp`'s `connectHttp` discards the
146
+ * underlying `StreamableHTTPError` (which carries the HTTP status on `code`)
147
+ * when the SSE-fallback path also fails, throwing a bodyless generic error
148
+ * instead. The harness's remote-transport fetch wrapper records the
149
+ * `{ status, body }` it saw; this rebuilds the shape `classifyMcpError` /
150
+ * {@link buildMcpErrorDetail} need so the status survives to `McpServerInfo`
151
+ * and the `mcp-server-discovery-failed` event (W-23231658, #617).
152
+ *
153
+ * The resulting message mirrors the MCP SDK's own `StreamableHTTPError` text
154
+ * (`Streamable HTTP error: ...`) so message-substring consumers see a familiar
155
+ * shape, and `code` carries the HTTP status so the layered classifier routes it
156
+ * to `http-4xx` / `http-5xx` / `http-401` / `http-403`. The original generic
157
+ * error is attached as `cause` so its stack is preserved on the failed event.
158
+ */
159
+ export function synthesizeHttpTransportError(status, body, cause) {
160
+ const suffix = body ? `: ${body}` : '';
161
+ const err = new Error(`Streamable HTTP error: HTTP ${status}${suffix}`, cause !== undefined ? { cause } : undefined);
162
+ err.code = status;
163
+ return err;
164
+ }
114
165
  /**
115
166
  * Build the SDK-side {@link McpServerErrorDetail} from a caught error. The
116
167
  * underlying JSON-RPC `code` (when the error is MCP-shaped, see
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,32 +30,32 @@
30
30
  "dependencies": {
31
31
  "@ai-sdk/provider": "^3.0.10",
32
32
  "@anthropic-ai/bedrock-sdk": "^0.31.0",
33
- "@anthropic-ai/sdk": "^0.105.0",
34
- "@mastra/core": "^1.42.0",
35
- "@mastra/libsql": "^1.13.0",
33
+ "@anthropic-ai/sdk": "^0.106.0",
34
+ "@mastra/core": "^1.47.0",
35
+ "@mastra/libsql": "^1.14.2",
36
36
  "@mastra/mcp": "^1.10.0",
37
- "@mastra/memory": "^1.20.3",
37
+ "@mastra/memory": "^1.21.2",
38
38
  "@mastra/schema-compat": "^1.2.11",
39
39
  "@salesforce/agentic-common": "0.13.0",
40
40
  "openai": "^6.0.0"
41
41
  },
42
42
  "peerDependencies": {
43
- "@salesforce/sfdx-agent-sdk": "0.27.0"
43
+ "@salesforce/sfdx-agent-sdk": "0.28.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@eslint/js": "^10.0.1",
47
- "@salesforce/sfdx-agent-sdk": "0.27.0",
47
+ "@salesforce/sfdx-agent-sdk": "0.28.0",
48
48
  "@types/node": "^22.20.0",
49
49
  "@vitest/coverage-istanbul": "^4.1.8",
50
50
  "@vitest/eslint-plugin": "^1.6.20",
51
- "eslint": "^10.5.0",
51
+ "eslint": "^10.6.0",
52
52
  "eslint-config-prettier": "^10.1.8",
53
53
  "eslint-import-resolver-typescript": "^4.4.5",
54
54
  "eslint-plugin-import": "^2.32.0",
55
- "eslint-plugin-n": "^18.1.0",
55
+ "eslint-plugin-n": "^18.2.1",
56
56
  "globals": "^17.6.0",
57
57
  "lint-staged": "^17.0.7",
58
- "prettier": "^3.8.4",
58
+ "prettier": "^3.9.0",
59
59
  "rimraf": "^6.1.3",
60
60
  "tsx": "^4.22.4",
61
61
  "typescript": "^6.0.3",