Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/cross-bundle-error-instanceof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

`instanceof` on the SDK error classes (`ProtocolError` and its typed subclasses, `SdkError`/`SdkHttpError`, `OAuthError`, and the client's `SseError`, `UnauthorizedError`, and OAuth-client-flow error family — `OAuthClientFlowError` and its subclasses) now works across separately bundled copies of the SDK. The classes match by a stable brand (via `Symbol.hasInstance` and a registry symbol) instead of prototype identity, so a process that uses both `@modelcontextprotocol/client` and `@modelcontextprotocol/server` - a gateway, host, or in-process test - can check errors constructed by either package against the class re-exported by the other. Ordinary prototype-based `instanceof` is preserved as a fallback; user-defined subclasses keep plain prototype semantics. Notes: cross-bundle matching requires both copies to be at or after this release; brands assert identity, not field shape, across versions - keep reading fields defensively. As a side effect, a foreign-bundle `SdkError` used as an abort reason is now rethrown as-is instead of being wrapped as a `RequestTimeout`. Also: `UnauthorizedError` now sets `error.name` to `'UnauthorizedError'` (previously `'Error'`), and per-package conformance tests enforce that every exported error class participates in branding. Version-negotiation probing now recognizes `UnauthorizedError` (previously a dead name-string check) and propagates it unchanged, so `connect()` on an auth-gated server rejects with the original `UnauthorizedError` (previously wrapped as the `cause` of an `SdkError(EraNegotiationFailed)`) — run `finishAuth()` and reconnect, and the retry probes with the token.
21 changes: 20 additions & 1 deletion docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,24 @@ The SDK now distinguishes three error kinds:
3. **`SdkHttpError`** (extends `SdkError`) — HTTP transport errors with typed `.status`
and `.statusText`.

These classes (and `OAuthError`, the client's `SseError`, `UnauthorizedError`, and the
OAuth-client-flow error family) brand-match under `instanceof`, so checks work across
separately bundled copies of the SDK — e.g. a process using both
`@modelcontextprotocol/client` and `@modelcontextprotocol/server`. Fine print:

- **Version skew** — matching needs *both* copies at a brand-aware release; against an
older copy, behavior degrades to plain prototype `instanceof` (false across bundles).
During mixed-version rollouts, recognize errors without class identity: match
`error.name` plus the class's discriminant field (`code`, `status`), or reconstruct
typed protocol errors with `ProtocolError.fromError(code, message, data)`.
- **Worker boundaries** — `structuredClone`/`postMessage` drop the (symbol-keyed) brand,
so a rehydrated error no longer brand-matches; recognize forwarded errors by
`code`/`data` instead.
- **Brands assert identity, not shape** — a matched instance from another SDK version
may lack newer fields; read fields defensively.
- **Re-bundling with property mangling** (`mangle.props` and similar) breaks the brand
statics; default esbuild/webpack/terser settings are safe.

The codemod renames `McpError` → `ProtocolError`, `ErrorCode` → `ProtocolErrorCode`
(routing `RequestTimeout` / `ConnectionClosed` to `SdkErrorCode`), and
`StreamableHTTPError` → `SdkHttpError`. After the codemod runs, your `instanceof`
Expand Down Expand Up @@ -980,7 +998,8 @@ peers as `-32602` — a server can no longer emit `-32002` on the wire.
`ProtocolErrorCode.ResourceNotFound` (`-32002`) stays importable as
receive-tolerated vocabulary — accept both `-32602` and `-32002` from peers.
`ProtocolError.fromError(code, message, data)` reconstructs the typed subclass from
code + data alone, so it works across bundle boundaries where `instanceof` doesn't.
code + data alone — the version-agnostic path: it also works on plain wire shapes and
against SDK copies that predate brand-matched `instanceof`.
The default message text changed alongside: v1's unknown-resource error read
`Resource <uri> not found`; v2's `ResourceNotFoundError` default is
`Resource not found: <uri>` (the code is unchanged). Tests pinning the exact string
Expand Down
2 changes: 1 addition & 1 deletion docs/servers/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ Three more subclasses cover the other structured protocol errors:
- `UnsupportedProtocolVersionError({ supported, requested })` — `-32022`; `data.supported` lets the peer pick a version and retry.
- `MissingRequiredClientCapabilityError({ requiredCapabilities })` — `-32021`; `data.requiredCapabilities` names exactly what the client must declare.

Match these by `code` and `data` shape, not by `instanceof` — `instanceof` fails across separately bundled copies of the SDK.
Match these by `code` and `data` shape when peers may run pre-brand SDK copies or hand you plain wire shapes; on brand-aware releases `instanceof` also matches across separately bundled copies of the SDK.

## Look up a protocol error code

Expand Down
11 changes: 6 additions & 5 deletions examples/oauth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,12 @@ let challenged = false;
try {
await client.connect(firstTransport);
} catch (error) {
// Under `--legacy` the transport surfaces `UnauthorizedError` directly;
// under `mode: 'auto'` the version-negotiation probe is what got 401'd
// and wraps it in an EraNegotiationFailed `SdkError` whose `data.cause`
// is the original `UnauthorizedError`. Either way the auth driver has
// already run by the time we land here — DCR done, auth URL captured.
// Both `--legacy` and `mode: 'auto'` surface the original
// `UnauthorizedError` directly (the negotiation probe propagates it
// unchanged; older releases wrapped it as the `data.cause` of an
// EraNegotiationFailed `SdkError`, which the unwrap below still
// tolerates). Either way the auth driver has already run by the time we
// land here — DCR done, auth URL captured.
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
if (!(root instanceof UnauthorizedError)) throw error;
challenged = true;
Expand Down
12 changes: 12 additions & 0 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import type {
StoredOAuthTokens
} from '@modelcontextprotocol/core-internal';
import {
brandedHasInstance,
checkResourceAllowed,
stampErrorBrands,
LATEST_PROTOCOL_VERSION,
OAuthClientInformationFullSchema,
OAuthError,
Expand Down Expand Up @@ -486,8 +488,18 @@ export interface OAuthDiscoveryState extends OAuthServerInfo {
export type AuthResult = 'AUTHORIZED' | 'REDIRECT';

export class UnauthorizedError extends Error {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.UnauthorizedError' });
}

static override [Symbol.hasInstance](value: unknown): boolean {
return brandedHasInstance(this, value);
}

constructor(message?: string) {
super(message ?? 'Unauthorized');
this.name = 'UnauthorizedError';
stampErrorBrands(this, new.target);
}
}

Expand Down
30 changes: 30 additions & 0 deletions packages/client/src/client/authErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type { OAuthClientMetadata } from '@modelcontextprotocol/core-internal';
import { brandedHasInstance, stampErrorBrands } from '@modelcontextprotocol/core-internal';

/**
* Base class for the OAuth-client-flow error family. Concrete subclasses are
Expand All @@ -19,9 +20,18 @@ import type { OAuthClientMetadata } from '@modelcontextprotocol/core-internal';
* hook and will not match anything until the first behavior change ships.
*/
export class OAuthClientFlowError extends Error {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.OAuthClientFlowError' });
}

static override [Symbol.hasInstance](value: unknown): boolean {
return brandedHasInstance(this, value);
}

constructor(message: string) {
super(message);
this.name = new.target.name;
stampErrorBrands(this, new.target);
}
}

Expand All @@ -45,6 +55,10 @@ export class OAuthClientFlowError extends Error {
* end users. The values are JSON-encoded in the message to neutralize log-injection.
*/
export class IssuerMismatchError extends OAuthClientFlowError {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.IssuerMismatchError' });
}

/** Which check failed — metadata echo (RFC 8414 §3.3) or authorization-response `iss` (RFC 9207). */
readonly kind: 'metadata' | 'authorization_response';
/** The issuer the client expected (from validated metadata / discovery input). */
Expand Down Expand Up @@ -79,6 +93,10 @@ export class IssuerMismatchError extends OAuthClientFlowError {
* path.
*/
export class RegistrationRejectedError extends OAuthClientFlowError {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.RegistrationRejectedError' });
}

/** HTTP status code returned by the registration endpoint. */
public readonly status: number;
/** Raw response body text (typically an RFC 7591 error JSON document). */
Expand All @@ -102,6 +120,10 @@ export class RegistrationRejectedError extends OAuthClientFlowError {
* of falling through to a fresh `/authorize` redirect.
*/
export class InsecureTokenEndpointError extends OAuthClientFlowError {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.InsecureTokenEndpointError' });
}

/** The token endpoint URL that was rejected. */
public readonly tokenEndpoint: string;

Expand Down Expand Up @@ -145,6 +167,10 @@ export class InsecureTokenEndpointError extends OAuthClientFlowError {
* client credentials are protected structurally by the `issuer` stamp instead.
*/
export class AuthorizationServerMismatchError extends OAuthClientFlowError {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.AuthorizationServerMismatchError' });
}

constructor(
/** The issuer recorded in `discoveryState()` when the authorization redirect was issued. */
public readonly recordedIssuer: string,
Expand All @@ -160,6 +186,10 @@ export class AuthorizationServerMismatchError extends OAuthClientFlowError {
}

export class InsufficientScopeError extends OAuthClientFlowError {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.InsufficientScopeError' });
}

/** The `scope` value from the `WWW-Authenticate` challenge — the scopes the resource server says are required. */
readonly requiredScope?: string;
/** The `resource_metadata` URL from the `WWW-Authenticate` challenge, if present. */
Expand Down
11 changes: 11 additions & 0 deletions packages/client/src/client/probeClassifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export type ProbeOutcome =
/** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text, when available. */
| { kind: 'http-error'; status: number; body?: string }
| { kind: 'network-error'; error: unknown }
/** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */
| { kind: 'auth-required'; error: Error }
/** No response arrived within the probe timeout. */
| { kind: 'timeout'; timeoutMs: number };

Expand Down Expand Up @@ -114,6 +116,15 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi
case 'network-error': {
return classifyNetworkError(outcome.error, context);
}
case 'auth-required': {
// Not era evidence: propagate the auth challenge unchanged so the
// caller can run finishAuth() and reconnect — the reconnect probes
// again with the token and settles the era with real evidence.
// Converting to a legacy fallback here would re-run the auth flow
// inside the same connect (a second authorization prompt) and then
// handshake an auth-gated modern server as legacy.
return { kind: 'error', error: outcome.error };
}
case 'timeout': {
if (context.transportKind === 'stdio') {
// Per the stdio transport's backward-compatibility rule, a probe
Expand Down
13 changes: 12 additions & 1 deletion packages/client/src/client/sse.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
import {
brandedHasInstance,
createFetchWithInit,
JSONRPCMessageSchema,
normalizeHeaders,
SdkError,
SdkErrorCode,
SdkHttpError
SdkHttpError,
stampErrorBrands
} from '@modelcontextprotocol/core-internal';
import type { ErrorEvent, EventSourceInit } from 'eventsource';
import { EventSource } from 'eventsource';
Expand All @@ -23,12 +25,21 @@ import {
import type { IssuerMismatchError } from './authErrors';

export class SseError extends Error {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.SseError' });
}

static override [Symbol.hasInstance](value: unknown): boolean {
return brandedHasInstance(this, value);
}

constructor(
public readonly code: number | undefined,
message: string | undefined,
public readonly event: ErrorEvent
) {
super(`SSE error: ${message}`);
stampErrorBrands(this, new.target);
}
}

Expand Down
17 changes: 13 additions & 4 deletions packages/client/src/client/versionNegotiation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
SUPPORTED_MODERN_PROTOCOL_VERSIONS
} from '@modelcontextprotocol/core-internal';

import { UnauthorizedError } from './auth';
import type { ProbeEnvironment, ProbeOutcome, ProbeTransportKind, ProbeVerdict } from './probeClassifier';
import { classifyProbeOutcome } from './probeClassifier';

Expand Down Expand Up @@ -293,10 +294,18 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome {
const text = (error.data as { text?: unknown } | undefined)?.text;
return { kind: 'http-error', status: error.data.status, body: typeof text === 'string' ? text : undefined };
}
if (error instanceof Error && error.name === 'UnauthorizedError') {
// Auth-gated server: not era evidence — the conservative legacy
// fallback re-runs the auth flow through the plain connect path.
return { kind: 'http-error', status: 401 };
const isUnauthorized =
error instanceof UnauthorizedError ||
// Name fallback for auth errors the brand cannot reach: an
// UnauthorizedError from a differently bundled SDK copy at a
// skewed version, or an auth middleware's own class.
(error instanceof Error && error.name === 'UnauthorizedError');
if (isUnauthorized) {
// Auth-gated server. (The pre-branding name-string check alone
// could never fire for the SDK's own class — it did not set
// `.name` — so these send failures fell through to the generic
// network-error wrap.)
return { kind: 'auth-required', error: error as Error };
}
return { kind: 'network-error', error };
}
Expand Down
Loading
Loading