Skip to content

v2 backwards compat: SdkError status code#2049

Merged
KKonstantinov merged 6 commits into
mainfrom
feature/v2-compat-fix-sdk-error-data
May 21, 2026
Merged

v2 backwards compat: SdkError status code#2049
KKonstantinov merged 6 commits into
mainfrom
feature/v2-compat-fix-sdk-error-data

Conversation

@KKonstantinov
Copy link
Copy Markdown
Contributor

Add SdkHttpError subclass for typed HTTP transport error handling

Motivation and Context

SdkError carries HTTP failure details (status code, status text) in its data?: unknown field. Consumers who need to inspect the HTTP status — for example, to handle 401 or 403 responses — must resort to unsafe casting:

if (error instanceof SdkError && (error.data as any)?.status === 401) { ... }

This also affects v1 migration: the old StreamableHTTPError had a typed code: number for HTTP status, but the v2 replacement SdkError uses code: SdkErrorCode (an enum, not an HTTP status). There is no typed way to access the HTTP status in v2.

How Has This Been Tested?

  • pnpm build:all — all packages build successfully
  • pnpm typecheck:all — no type errors
  • pnpm lint:all — no lint or formatting issues
  • pnpm test:all — all existing tests pass (core: 552, client: 364)
  • Updated test assertions in streamableHttp.test.ts, tokenProvider.test.ts, and sse.test.ts to verify instanceof SdkHttpError and the .status accessor

Breaking Changes

None. SdkHttpError extends SdkError, so existing catch clauses and instanceof SdkError checks continue to work. This is a purely additive change — consumers can opt in to more precise error handling via instanceof SdkHttpError without changing existing code.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Alternatives Considered

A. Generic SdkError<C> with data type map — A SdkErrorDataMap mapping each SdkErrorCode to its data shape, with SdkError<C>.data conditionally typed. Rejected because TypeScript doesn't narrow class generics via property checks — consumers can't write if (error.code === SdkErrorCode.X) and get narrowed data. Requires a helper function, which is non-standard for error handling.

B. Broaden SdkError.data to { status?: number; statusText?: string; ... } — Rejected because it's misleading: suggests every SdkError might have HTTP status fields, even NotConnected or RequestTimeout which have no HTTP context.

C. Add httpStatus accessor on SdkError base classget httpStatus(): number | undefined that extracts from data. Rejected because the base class shouldn't know about HTTP semantics, and consumers can't distinguish HTTP errors structurally at the type level.

D. HTTP error subclass (chosen)SdkHttpError extends SdkError with typed data. Standard instanceof pattern, backward compatible, follows established SDK patterns (AWS SDK v3 ServiceException, Stripe StripeAPIError, gRPC ServiceError). One new class, no generics, no type maps.

Additional context

Files changed:

  • packages/core/src/errors/sdkErrors.ts — Added SdkHttpErrorData interface and SdkHttpError class with status/statusText getters
  • packages/core/src/exports/public/index.ts — Exported SdkHttpError and SdkHttpErrorData
  • packages/client/src/client/streamableHttp.ts — Changed 5 throw sites from SdkError to SdkHttpError for HTTP failures
  • packages/client/src/client/sse.ts — Changed 1 throw site (401 after retry) from SdkError to SdkHttpError
  • packages/client/test/client/streamableHttp.test.ts — Updated assertions to use SdkHttpError
  • packages/client/test/client/tokenProvider.test.ts — Updated assertions to use SdkHttpError
  • packages/client/test/client/sse.test.ts — Updated assertions to use SdkHttpError

Design decision: The ClientHttpUnexpectedContent error in streamableHttp.ts was intentionally left as SdkError — it fires on a 200 OK response with an unexpected content type, so it doesn't carry an HTTP error status and doesn't fit the SdkHttpErrorData shape.

Consumer usage after this change:

import { SdkHttpError } from '@modelcontextprotocol/client';

try {
    await client.callTool({ name: 'foo' });
} catch (error) {
    if (error instanceof SdkHttpError) {
        console.log(error.status);      // number — no cast needed
        console.log(error.statusText);  // string | undefined
        console.log(error.code);        // SdkErrorCode (inherited)
    }
}

@KKonstantinov KKonstantinov requested a review from a team as a code owner May 11, 2026 15:44
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 11, 2026

🦋 Changeset detected

Latest commit: bfdca86

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@modelcontextprotocol/core Minor
@modelcontextprotocol/client Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 11, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2049

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2049

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2049

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2049

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2049

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2049

commit: bfdca86

Comment thread packages/core/src/errors/sdkErrors.ts
Comment thread packages/client/test/client/streamableHttp.test.ts Outdated
Comment thread packages/core/src/exports/public/index.ts
Comment thread packages/core/src/errors/sdkErrors.ts
Comment thread .changeset/add-sdk-http-error.md Outdated
Comment thread docs/migration-SKILL.md
Comment thread packages/client/src/client/streamableHttp.ts
Comment on lines +107 to +109
get statusText(): string | undefined {
return this.data.statusText;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: The new .statusText getter has no test assertions — the three tests this PR touched assert (error as SdkHttpError).status but none assert .statusText, and there's no SdkHttpError unit test in packages/core/test/. Since commit bfdca86 now populates statusText at every throw site, consider adding expect((error as SdkHttpError).statusText).toBe('Unauthorized') alongside the existing .status checks (e.g. streamableHttp.test.ts:1877) so both new accessors are covered.

Extended reasoning...

What

This PR adds two new public getters to SdkHttpError (packages/core/src/errors/sdkErrors.ts:101-109):

get status(): number { return this.data.status; }
get statusText(): string | undefined { return this.data.statusText; }

The .status getter is asserted in three tests this PR touched — streamableHttp.test.ts:1877, sse.test.ts:1592, and tokenProvider.test.ts:104 — each adds expect((error as SdkHttpError).status).toBe(401) directly below the instanceof / .code assertions. But the parallel .statusText getter has zero assertions anywhere in the test suite.

Step-by-step proof

  1. Grep packages/**/test/**/*.ts for .statusText → the only hits are mock Response objects being constructed (e.g. streamableHttp.test.ts:234 statusText: 'Not Found', :769 statusText: 'Forbidden', :1835 statusText: 'Unauthorized') and an unrelated response.statusText read in middleware.test.ts. None are expect(...).statusText assertions on an SdkHttpError instance.
  2. The 404 test at streamableHttp.test.ts:243-247 does rejects.toThrow(new SdkHttpError(..., { statusText: 'Not Found', ... })), but vitest's toThrow(errorInstance) matches only on .message — it does not compare data fields or exercise the getter, so this is not coverage of .statusText.
  3. Grep packages/core/test/ for SdkHttpError → no matches; there is no unit test for the class itself.
  4. ⇒ The new public .statusText accessor — advertised in the changeset (.changeset/add-sdk-http-error.md), the JSDoc @example, and both migration docs (docs/migration.md:755, docs/migration-SKILL.md:168) — ships with no test verifying it returns what was passed in.

Why this is worth a nit now

The previous review round (#3247761715) specifically asked the author to populate statusText at the five throw sites that were omitting it, and commit bfdca86 did so — every SdkHttpError throw site now passes statusText: response.statusText. So the data plumbing exists end-to-end, but the only half of the new accessor pair that's actually verified is .status. Per REVIEW.md § Tests & docs ("New behavior has vitest coverage"), a new public getter that's documented in the changelog and migration guide should have at least one assertion.

Why nothing catches it

The getter is a one-line passthrough (return this.data.statusText) structurally identical to the already-tested .status getter, so type-checking and the existing suite are happy. There's no coverage threshold gate on accessor lines. Risk of an actual bug is near-zero — hence nit, not a blocker.

Fix

Add a one-liner alongside any of the existing .status assertions, e.g. in streamableHttp.test.ts (the mock response at line 1835 already sets statusText: 'Unauthorized'):

expect((error as SdkHttpError).status).toBe(401);
expect((error as SdkHttpError).statusText).toBe('Unauthorized');

Or add a tiny unit test in packages/core/test/errors/sdkErrors.test.ts covering both getters directly:

const e = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'msg', { status: 500, statusText: 'Internal Server Error' });
expect(e.status).toBe(500);
expect(e.statusText).toBe('Internal Server Error');

@KKonstantinov KKonstantinov merged commit 4f226c1 into main May 21, 2026
18 checks passed
@KKonstantinov KKonstantinov deleted the feature/v2-compat-fix-sdk-error-data branch May 21, 2026 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants