Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/client/src/client/probeClassifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,12 @@ function classifyNetworkError(error: unknown, context: ProbeClassifierContext):
}
return {
kind: 'error',
error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, {
cause: error
})
error: new SdkError(
SdkErrorCode.EraNegotiationFailed,
`Version negotiation probe failed: ${describeError(error)}`,
undefined,
{ cause: error }
)
};
}

Expand Down
43 changes: 35 additions & 8 deletions packages/core-internal/src/errors/sdkErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,27 @@ export enum SdkErrorCode {
* }
* ```
*/
/**
* Peel a mistaken `{ cause }` off the `data` bag so it can be forwarded to
* `Error`'s options. Call sites historically passed `{ cause }` as the third
* argument because TypeScript accepts any `data`; without this peel the
* underlying error lands in `.data.cause` and never on `.cause`, which breaks
* pino / Sentry cause-chain walkers.
*/
function splitSdkErrorData(data: unknown): { data?: unknown; cause?: unknown } {
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
return { data };
}
if (!('cause' in data)) {
return { data };
}
const { cause, ...rest } = data as Record<string, unknown>;
return {
cause,
data: Object.keys(rest).length > 0 ? rest : undefined
};
}

export class SdkError extends Error {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.SdkError' });
Expand Down Expand Up @@ -144,12 +165,18 @@ export class SdkError extends Error {
return brandedHasInstance(this, value);
}

constructor(
public readonly code: SdkErrorCode,
message: string,
public readonly data?: unknown
) {
super(message);
/**
* Optional structured payload (HTTP status fields, timeout ms, etc.).
* Distinct from {@link Error.cause} — pass causes via `options` or as a
* `{ cause }` key inside `data` (peeled into `.cause` for back-compat).
*/
public readonly data?: unknown;

constructor(public readonly code: SdkErrorCode, message: string, data?: unknown, options?: ErrorOptions) {
const split = splitSdkErrorData(data);
const cause = options?.cause ?? split.cause;
super(message, cause !== undefined ? { ...options, cause } : options);
this.data = split.data;
this.name = 'SdkError';
stampErrorBrands(this, new.target);
}
Expand Down Expand Up @@ -187,8 +214,8 @@ export class SdkHttpError extends SdkError {

declare readonly data: SdkHttpErrorData;

constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData) {
super(code, message, data);
constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData, options?: ErrorOptions) {
super(code, message, data, options);
this.name = 'SdkHttpError';
}

Expand Down
59 changes: 59 additions & 0 deletions packages/core-internal/test/errors/sdkError.cause.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { SdkError, SdkErrorCode, SdkHttpError } from '../../src/index';

describe('SdkError cause forwarding', () => {
it('forwards ErrorOptions.cause onto Error.cause', () => {
const root = new TypeError('fetch failed');
const error = new SdkError(SdkErrorCode.EraNegotiationFailed, 'probe failed', undefined, { cause: root });

expect(error.cause).toBe(root);
expect(error.data).toBeUndefined();
});

it('peels a mistaken { cause } out of the data bag onto Error.cause', () => {
const root = new TypeError('fetch failed');
Object.defineProperty(root, 'cause', {
value: new Error('getaddrinfo ENOTFOUND does-not-resolve.invalid'),
configurable: true
});

// Historical call shape: third arg is `data`, but sites passed `{ cause }`.
const error = new SdkError(SdkErrorCode.EraNegotiationFailed, 'Version negotiation probe failed: fetch failed', {
cause: root
});

expect(error.cause).toBe(root);
expect(error.data).toBeUndefined();
expect((error.cause as Error).cause).toBeInstanceOf(Error);
expect(((error.cause as Error).cause as Error).message).toContain('ENOTFOUND');
});

it('keeps sibling data fields when peeling cause', () => {
const root = new Error('boom');
const error = new SdkError(SdkErrorCode.RequestTimeout, 'timed out', { timeout: 5_000, cause: root });

expect(error.cause).toBe(root);
expect(error.data).toEqual({ timeout: 5_000 });
});

it('does not invent a cause for ordinary data bags', () => {
const error = new SdkError(SdkErrorCode.RequestTimeout, 'timed out', { timeout: 5_000 });

expect(error.cause).toBeUndefined();
expect(error.data).toEqual({ timeout: 5_000 });
});

it('preserves SdkHttpError status data while allowing an options cause', () => {
const root = new Error('socket hang up');
const error = new SdkHttpError(
SdkErrorCode.ClientHttpFailedToOpenStream,
'stream failed',
{ status: 502, statusText: 'Bad Gateway' },
{ cause: root }
);

expect(error).toBeInstanceOf(SdkHttpError);
expect(error.status).toBe(502);
expect(error.cause).toBe(root);
});
});
Loading