fix(core): make instanceof on SDK error classes work across bundled copies (#2384)

This commit is contained in:
Felix Weinberger 2026-07-06 15:53:12 +01:00 • committed by GitHub
commit ce2f65db0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1020 additions and 26 deletions

View file

@ -13,7 +13,9 @@ import type {
StoredOAuthTokens
} from '@modelcontextprotocol/core-internal';
import {
brandedHasInstance,
checkResourceAllowed,
stampErrorBrands,
LATEST_PROTOCOL_VERSION,
OAuthClientInformationFullSchema,
OAuthError,
@ -486,8 +488,36 @@ 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);
}
/**
* Brand-based type guard: equivalent to `value instanceof this`, as an
* explicit static predicate (the axios/AWS-SDK `isInstance` style). Reads
* the caller's own brand via `this`, so every branded subclass gets a
* correctly-scoped guard by inheritance. Must be invoked on the class —
* in callback position write `v => SdkError.isInstance(v)`, not
* `.filter(SdkError.isInstance)` (detached calls throw rather than
* silently matching nothing).
*/
static isInstance<T extends abstract new (...args: never[]) => unknown>(this: T, value: unknown): value is InstanceType<T> {
if (typeof this !== 'function') {
throw new TypeError(
'isInstance must be called on the class (e.g. `SdkError.isInstance(value)`); for callbacks use `v => SdkError.isInstance(v)`'
);
}
return brandedHasInstance(this, value);
}
constructor(message?: string) {
super(message ?? 'Unauthorized');
this.name = 'UnauthorizedError';
stampErrorBrands(this, new.target);
}
}

View file

@ -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
@ -19,9 +20,36 @@ 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);
}
/**
* Brand-based type guard: equivalent to `value instanceof this`, as an
* explicit static predicate (the axios/AWS-SDK `isInstance` style). Reads
* the caller's own brand via `this`, so every branded subclass gets a
* correctly-scoped guard by inheritance. Must be invoked on the class —
* in callback position write `v => SdkError.isInstance(v)`, not
* `.filter(SdkError.isInstance)` (detached calls throw rather than
* silently matching nothing).
*/
static isInstance<T extends abstract new (...args: never[]) => unknown>(this: T, value: unknown): value is InstanceType<T> {
if (typeof this !== 'function') {
throw new TypeError(
'isInstance must be called on the class (e.g. `SdkError.isInstance(value)`); for callbacks use `v => SdkError.isInstance(v)`'
);
}
return brandedHasInstance(this, value);
}
constructor(message: string) {
super(message);
this.name = new.target.name;
stampErrorBrands(this, new.target);
}
}
@ -45,6 +73,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). */
@ -79,6 +111,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). */
@ -102,6 +138,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;
@ -145,6 +185,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,
@ -160,6 +204,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. */

View file

@ -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 };
@ -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

View file

@ -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';
@ -23,12 +25,39 @@ 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);
}
/**
* Brand-based type guard: equivalent to `value instanceof this`, as an
* explicit static predicate (the axios/AWS-SDK `isInstance` style). Reads
* the caller's own brand via `this`, so every branded subclass gets a
* correctly-scoped guard by inheritance. Must be invoked on the class —
* in callback position write `v => SdkError.isInstance(v)`, not
* `.filter(SdkError.isInstance)` (detached calls throw rather than
* silently matching nothing).
*/
static isInstance<T extends abstract new (...args: never[]) => unknown>(this: T, value: unknown): value is InstanceType<T> {
if (typeof this !== 'function') {
throw new TypeError(
'isInstance must be called on the class (e.g. `SdkError.isInstance(value)`); for callbacks use `v => SdkError.isInstance(v)`'
);
}
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);
}
}

View file

@ -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';
@ -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 };
}

View file

@ -0,0 +1,130 @@
import { describe, expect, it, vi } from 'vitest';
import * as barrel from '../../src/index';
/**
* Enforces the cross-bundle branding participation criterion from
* `core-internal/src/errors/crossBundleBrand.ts`: every error class exported
* from this package's public surface must carry an own `mcpBrand` static and
* resolve `instanceof` through a branded `Symbol.hasInstance`.
*
* This is the infrastructure that replaces "remember the static block": adding
* a new exported error class without branding turns this test red naming the
* class, instead of shipping a subclass whose cross-bundle `instanceof`
* silently returns false while its parent still matches.
*/
/** Error classes exported on purpose without a brand. Justify every entry. */
const UNBRANDED_ALLOWLIST: ReadonlySet<string> = new Set();
function exportedErrorClasses(mod: Record<string, unknown>): Array<[string, Function]> {
return Object.entries(mod)
.filter((entry): entry is [string, Function] => {
const v = entry[1];
return typeof v === 'function' && v !== Error && v.prototype instanceof Error;
})
.sort(([a], [b]) => a.localeCompare(b));
}
describe('error brand conformance (client export surface)', () => {
const classes = exportedErrorClasses(barrel as Record<string, unknown>);
it('finds the export surface (guards against a broken walker)', () => {
expect(classes.length).toBeGreaterThanOrEqual(15);
expect(classes.map(([n]) => n)).toContain('ProtocolError');
expect(classes.map(([n]) => n)).toContain('OAuthClientFlowError');
});
it('every exported error class is branded (or explicitly allowlisted)', () => {
const unbranded = classes
.filter(([name]) => !UNBRANDED_ALLOWLIST.has(name))
.filter(([, cls]) => !Object.prototype.hasOwnProperty.call(cls, 'mcpBrand'))
.map(([name]) => name);
expect(
unbranded,
`unbranded exported error classes (add the static mcpBrand block, or allowlist with a justification): ${unbranded.join(', ')}`
).toEqual([]);
});
it('every branded class resolves instanceof through a branded hasInstance', () => {
const missing = classes
.filter(([, cls]) => Object.prototype.hasOwnProperty.call(cls, 'mcpBrand'))
.filter(([, cls]) => (cls as never as Record<symbol, unknown>)[Symbol.hasInstance] === Function.prototype[Symbol.hasInstance])
.map(([name]) => name);
expect(missing, `branded classes whose hierarchy root does not install brandedHasInstance: ${missing.join(', ')}`).toEqual([]);
});
// Core-internal brand strings are pinned once, in core-internal's
// errorSurfacePins.test.ts — this test only asserts the core re-export SET
// so a rename there is not double-maintained here.
const CORE_PINNED = new Set([
'MissingRequiredClientCapabilityError',
'OAuthError',
'ProtocolError',
'ResourceNotFoundError',
'SdkError',
'SdkHttpError',
'UnsupportedProtocolVersionError',
'UrlElicitationRequiredError'
]);
it('pins every client-local brand string (renaming one severs cross-version matching — must be deliberate)', () => {
const brands = Object.fromEntries(
classes
.filter(([name, cls]) => !CORE_PINNED.has(name) && Object.prototype.hasOwnProperty.call(cls, 'mcpBrand'))
.map(([name, cls]) => [name, (cls as never as { mcpBrand: string }).mcpBrand])
);
expect(brands).toEqual({
AuthorizationServerMismatchError: 'mcp.AuthorizationServerMismatchError',
InsecureTokenEndpointError: 'mcp.InsecureTokenEndpointError',
InsufficientScopeError: 'mcp.InsufficientScopeError',
IssuerMismatchError: 'mcp.IssuerMismatchError',
OAuthClientFlowError: 'mcp.OAuthClientFlowError',
RegistrationRejectedError: 'mcp.RegistrationRejectedError',
SseError: 'mcp.SseError',
UnauthorizedError: 'mcp.UnauthorizedError'
});
});
it('every branded class exposes an isInstance guard that agrees with instanceof', () => {
const branded = classes.filter(([, cls]) => Object.prototype.hasOwnProperty.call(cls, 'mcpBrand'));
const missing = branded.filter(([, cls]) => typeof (cls as { isInstance?: unknown }).isInstance !== 'function').map(([n]) => n);
expect(missing, `branded classes without a static isInstance guard: ${missing.join(', ')}`).toEqual([]);
for (const [, cls] of branded) {
const guard = (cls as unknown as { isInstance: (v: unknown) => boolean }).isInstance;
for (const v of [new Error('plain'), null, undefined, 0, '', {}]) {
expect(guard.call(cls, v)).toBe(v instanceof (cls as never as new () => unknown));
}
}
});
it('core re-exported error classes are exactly the set pinned in errorSurfacePins.test.ts', () => {
const coreExported = classes.map(([name]) => name).filter(name => CORE_PINNED.has(name));
expect(coreExported).toEqual([...CORE_PINNED].sort((a, b) => a.localeCompare(b)));
});
it('the OAuth flow family and UnauthorizedError match across module copies', async () => {
vi.resetModules();
const foreignAuthErrors = await import('../../src/client/authErrors');
const foreignAuth = await import('../../src/client/auth');
// Guard the premise: a cached module would make every check below pass
// trivially through prototype identity.
expect(foreignAuthErrors.IssuerMismatchError).not.toBe(barrel.IssuerMismatchError);
expect(foreignAuth.UnauthorizedError).not.toBe(barrel.UnauthorizedError);
const issuer = new foreignAuthErrors.IssuerMismatchError('metadata', 'a', 'b');
expect(issuer instanceof barrel.IssuerMismatchError).toBe(true);
expect(issuer instanceof barrel.OAuthClientFlowError).toBe(true);
expect(issuer instanceof barrel.UnauthorizedError).toBe(false);
const unauthorized = new foreignAuth.UnauthorizedError('nope');
expect(unauthorized instanceof barrel.UnauthorizedError).toBe(true);
expect(unauthorized.name).toBe('UnauthorizedError');
// the isInstance guards read the same brands: cross-copy agreement
expect(barrel.IssuerMismatchError.isInstance(issuer)).toBe(true);
expect(barrel.OAuthClientFlowError.isInstance(issuer)).toBe(true);
expect(barrel.UnauthorizedError.isInstance(issuer)).toBe(false);
expect(barrel.UnauthorizedError.isInstance(unauthorized)).toBe(true);
});
});

View file

@ -0,0 +1,26 @@
import { describe, expect, it, vi } from 'vitest';
import { SseError } from '../../src/client/sse';
describe('SseError cross-bundle instanceof', () => {
it('same-bundle instanceof and fields keep working', () => {
const err = new SseError(401, 'unauthorized', {} as never);
expect(err instanceof SseError).toBe(true);
expect(err instanceof Error).toBe(true);
expect(err.code).toBe(401);
});
it('an instance from a second module copy satisfies instanceof against this copy', async () => {
vi.resetModules();
const copy2 = await import('../../src/client/sse');
expect(copy2.SseError).not.toBe(SseError);
const foreign = new copy2.SseError(403, 'forbidden', {} as never);
expect(foreign instanceof SseError).toBe(true);
});
it('stays disjoint from the SdkError hierarchy', async () => {
const { SdkError } = await import('@modelcontextprotocol/core-internal');
const err = new SseError(500, 'boom', {} as never);
expect((err as unknown) instanceof SdkError).toBe(false);
});
});

View file

@ -18,6 +18,7 @@ import {
} from '@modelcontextprotocol/core-internal';
import { describe, expect, test } from 'vitest';
import { UnauthorizedError } from '../../src/client/auth';
import { Client } from '../../src/client/client';
import type { StreamableHTTPClientTransportOptions } from '../../src/client/streamableHttp';
import type { StdioServerParameters } from '../../src/client/stdio';
@ -706,3 +707,78 @@ describe('era scope discipline', () => {
expect(noCachedEra).toBe(true);
});
});
/* ------------------------------------------------------------------------- *
* Probe send-error classification: auth-gated servers propagate the
* UnauthorizedError unchanged (finishAuth() + reconnect probes again); other
* send failures stay typed negotiation errors.
* ------------------------------------------------------------------------- */
describe('probe send-error classification', () => {
/** Rejects the probe send with `probeError`, then serves legacy initialize. */
class AuthGatedTransport extends ScriptedTransport {
constructor(private readonly probeError: Error) {
super(legacyServerScript);
}
override async send(message: JSONRPCMessage): Promise<void> {
if (isJSONRPCRequest(message) && message.method === 'server/discover') {
throw this.probeError;
}
await super.send(message);
}
}
test('UnauthorizedError from the probe send propagates unchanged — no fallback, no second auth round (finishAuth + reconnect probes again)', async () => {
const reason = new UnauthorizedError();
const transport = new AuthGatedTransport(reason);
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
const rejection = await client.connect(transport).then(
() => {
throw new Error('connect unexpectedly resolved');
},
(e: unknown) => e
);
// The original error, unwrapped: callers dispatch finishAuth() on it.
expect(rejection).toBe(reason);
// No initialize fallback ran — that would re-trigger the transport's
// auth flow (a second authorization prompt) and pin an auth-gated
// modern server to the legacy era.
expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false);
});
test("a foreign auth error matching only by name === 'UnauthorizedError' propagates the same way", async () => {
class ForeignUnauthorizedError extends Error {
override readonly name = 'UnauthorizedError';
}
const reason = new ForeignUnauthorizedError('401 from middleware');
const transport = new AuthGatedTransport(reason);
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
const rejection = await client.connect(transport).then(
() => {
throw new Error('connect unexpectedly resolved');
},
(e: unknown) => e
);
expect(rejection).toBe(reason);
expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false);
});
test('a plain send failure stays a typed negotiation error — no fallback runs', async () => {
const transport = new AuthGatedTransport(new Error('connection refused'));
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
const rejection = await client.connect(transport).then(
() => {
throw new Error('connect unexpectedly resolved');
},
(e: unknown) => e
);
expect(rejection).toBeInstanceOf(SdkError);
expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false);
});
});

View file

@ -1,3 +1,4 @@
import { brandedHasInstance, stampErrorBrands } from '../errors/crossBundleBrand';
import type { OAuthErrorResponse } from '../shared/auth';
/**
@ -103,6 +104,32 @@ export enum OAuthErrorCode {
* OAuth error class for all OAuth-related errors.
*/
export class OAuthError extends Error {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.OAuthError' });
}
static override [Symbol.hasInstance](value: unknown): boolean {
return brandedHasInstance(this, value);
}
/**
* Brand-based type guard: equivalent to `value instanceof this`, as an
* explicit static predicate (the axios/AWS-SDK `isInstance` style). Reads
* the caller's own brand via `this`, so every branded subclass gets a
* correctly-scoped guard by inheritance. Must be invoked on the class —
* in callback position write `v => SdkError.isInstance(v)`, not
* `.filter(SdkError.isInstance)` (detached calls throw rather than
* silently matching nothing).
*/
static isInstance<T extends abstract new (...args: never[]) => unknown>(this: T, value: unknown): value is InstanceType<T> {
if (typeof this !== 'function') {
throw new TypeError(
'isInstance must be called on the class (e.g. `SdkError.isInstance(value)`); for callbacks use `v => SdkError.isInstance(v)`'
);
}
return brandedHasInstance(this, value);
}
constructor(
public readonly code: OAuthErrorCode | string,
message: string,
@ -110,6 +137,7 @@ export class OAuthError extends Error {
) {
super(message);
this.name = 'OAuthError';
stampErrorBrands(this, new.target);
}
/**

View file

@ -0,0 +1,117 @@
/**
* Cross-bundle `instanceof` support for the SDK error classes.
*
* `@modelcontextprotocol/client` and `@modelcontextprotocol/server` each bundle their
* own copy of `core-internal`, so an error constructed by one package fails a
* prototype-identity `instanceof` against the same class re-exported by the other —
* exactly the check a dual-role process (gateway, host, in-process test) writes.
*
* Instead of prototype identity, branded classes stamp every instance with the brand
* strings of its class chain under a registry symbol (`Symbol.for`, shared across
* bundles and realms), and resolve `instanceof` via `Symbol.hasInstance` against the
* brand set. Ordinary prototype-based `instanceof` is kept as a fallback so behavior
* is unchanged for anything unbranded.
*
* A class participates by defining an **own** `mcpBrand` static (via a `static {}`
* block, so nothing reaches the declaration files — a declared `protected static`
* field would make the constructor types nominally incompatible across the bundled
* copies) and (for hierarchy roots) installing {@linkcode brandedHasInstance} as
* `Symbol.hasInstance`. User-defined subclasses that do not declare their own brand
* keep plain prototype semantics — a foreign base-class instance never satisfies
* `instanceof UserSubclass`.
*
* Prior art — the same stamp-and-hook shape ships at scale elsewhere: Node core
* (stream.Writable since 2017, Console via a marker symbol, diagnostics_channel),
* undici's whole error hierarchy (vendored into Node as fetch), googleapis/gaxios
* (GaxiosError, Symbol.for marker), AWS SDK v3's ServiceException (which pairs a
* Symbol.hasInstance override with a static isInstance guard, as we do), and zod v4
* (Symbol.hasInstance on every schema class for cross-version interop).
*
* Contract notes:
* - Participation criterion: **every error class exported from a public package that
* callers are documented to `instanceof` must be branded.** The per-package
* errorBrandConformance tests walk the export surfaces and fail naming any
* exported Error subclass that has not opted in.
* - Brands assert **identity, not shape**: brand strings are version-less, so an
* instance from one SDK version matches the class of another. Members added to a
* branded class in a later version may be absent on a matched instance — read
* fields defensively, and treat branded classes as additive-only. The escape
* hatch when a release must break a branded class's read contract: change that
* class's brand string in the same release, which cleanly severs cross-version
* matching for that class. The per-package brand pins make the rename
* deliberate: errorSurfacePins.test.ts owns the core-internal brands, and each
* package's errorBrandConformance test pins its package-local ones.
* - Cross-bundle matching requires **both** copies to be at or after the release
* that introduced branding; against an older copy, behavior degrades to plain
* prototype `instanceof` in both directions.
* - A consumer re-bundling the SDK with property mangling (`mangle.props`) would
* break the brand statics; default esbuild/webpack/terser settings do not.
*/
/** Registry symbol — identical across bundled copies and realms. */
const BRANDS: unique symbol = Symbol.for('mcp.sdk.errorBrands') as never;
interface BrandCarrier {
[BRANDS]?: { has(brand: string): boolean };
}
interface BrandedConstructor {
mcpBrand?: string;
}
/**
* Stamp `instance` with the brand of every class in `ctor`'s chain that declares an
* own `mcpBrand`. Call once from the hierarchy root's constructor with `new.target` —
* subclasses inherit the stamping without touching their constructors.
*
* Constructor-time only: never stamp arbitrary objects. A stamped non-instance would
* satisfy `instanceof` while lacking the prototype members (getters like `.status`)
* that callers reach for after the check.
*/
export function stampErrorBrands(instance: object, ctor: unknown): void {
const brands = new Set<string>();
let current: unknown = ctor;
while (typeof current === 'function') {
const brand = (current as BrandedConstructor).mcpBrand;
if (Object.prototype.hasOwnProperty.call(current, 'mcpBrand') && typeof brand === 'string') {
brands.add(brand);
}
current = Object.getPrototypeOf(current);
}
if (brands.size === 0) return;
// configurable so userland instrumentation that clones/replaces error objects
// (e.g. wrapping via Object.defineProperties) is not broken by a frozen stamp;
// deleting the stamp merely degrades that instance to prototype `instanceof`.
Object.defineProperty(instance, BRANDS, { value: brands, enumerable: false, configurable: true });
}
/**
* `Symbol.hasInstance` implementation for branded hierarchy roots. Matches when the
* value carries the **own** brand of the class being tested against (cross-bundle
* path), falling back to ordinary prototype-based `instanceof` otherwise.
*/
export function brandedHasInstance(cls: object, value: unknown): boolean {
try {
if (
typeof value === 'object' &&
value !== null &&
Object.prototype.hasOwnProperty.call(cls, 'mcpBrand') &&
typeof (cls as BrandedConstructor).mcpBrand === 'string' &&
// Own-property only: a brand inherited via the prototype chain is never
// honored, so polluting Object.prototype with the registry symbol cannot
// make arbitrary objects satisfy instanceof (real instances are stamped
// with an own property by stampErrorBrands).
Object.prototype.hasOwnProperty.call(value, BRANDS)
) {
const carried = (value as BrandCarrier)[BRANDS];
if (carried && typeof carried.has === 'function' && carried.has((cls as BrandedConstructor).mcpBrand!)) {
return true;
}
}
} catch {
// A hostile Proxy trap or throwing accessor must not make `instanceof`
// throw where it previously returned false — fall through to the
// ordinary prototype check.
}
return Function.prototype[Symbol.hasInstance].call(cls, value);
}

View file

@ -1,3 +1,5 @@
import { brandedHasInstance, stampErrorBrands } from './crossBundleBrand';
/**
* Error codes for SDK errors (local errors that never cross the wire).
* Unlike {@linkcode ProtocolErrorCode} which uses numeric JSON-RPC codes, `SdkErrorCode` uses
@ -98,6 +100,32 @@ export enum SdkErrorCode {
* ```
*/
export class SdkError extends Error {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.SdkError' });
}
static override [Symbol.hasInstance](value: unknown): boolean {
return brandedHasInstance(this, value);
}
/**
* Brand-based type guard: equivalent to `value instanceof this`, as an
* explicit static predicate (the axios/AWS-SDK `isInstance` style). Reads
* the caller's own brand via `this`, so every branded subclass gets a
* correctly-scoped guard by inheritance. Must be invoked on the class —
* in callback position write `v => SdkError.isInstance(v)`, not
* `.filter(SdkError.isInstance)` (detached calls throw rather than
* silently matching nothing).
*/
static isInstance<T extends abstract new (...args: never[]) => unknown>(this: T, value: unknown): value is InstanceType<T> {
if (typeof this !== 'function') {
throw new TypeError(
'isInstance must be called on the class (e.g. `SdkError.isInstance(value)`); for callbacks use `v => SdkError.isInstance(v)`'
);
}
return brandedHasInstance(this, value);
}
constructor(
public readonly code: SdkErrorCode,
message: string,
@ -105,6 +133,7 @@ export class SdkError extends Error {
) {
super(message);
this.name = 'SdkError';
stampErrorBrands(this, new.target);
}
}
@ -134,6 +163,10 @@ export interface SdkHttpErrorData {
* ```
*/
export class SdkHttpError extends SdkError {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.SdkHttpError' });
}
declare readonly data: SdkHttpErrorData;
constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData) {

View file

@ -1,4 +1,5 @@
export * from './auth/errors';
export * from './errors/crossBundleBrand';
export * from './errors/sdkErrors';
export * from './shared/auth';
export * from './shared/authUtils';

View file

@ -1,3 +1,4 @@
import { brandedHasInstance, stampErrorBrands } from '../errors/crossBundleBrand';
import { ProtocolErrorCode } from './enums';
import type {
ClientCapabilities,
@ -9,8 +10,39 @@ import type {
/**
* Protocol errors are JSON-RPC errors that cross the wire as error responses.
* They use numeric error codes from the {@linkcode ProtocolErrorCode} enum.
*
* `instanceof` on this class (and its subclasses) is brand-matched, so it works
* across separately bundled copies of the SDK — e.g. an error constructed by
* `@modelcontextprotocol/client` matches the class re-exported by
* `@modelcontextprotocol/server` in the same process.
*/
export class ProtocolError extends Error {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.ProtocolError' });
}
static override [Symbol.hasInstance](value: unknown): boolean {
return brandedHasInstance(this, value);
}
/**
* Brand-based type guard: equivalent to `value instanceof this`, as an
* explicit static predicate (the axios/AWS-SDK `isInstance` style). Reads
* the caller's own brand via `this`, so every branded subclass gets a
* correctly-scoped guard by inheritance. Must be invoked on the class —
* in callback position write `v => SdkError.isInstance(v)`, not
* `.filter(SdkError.isInstance)` (detached calls throw rather than
* silently matching nothing).
*/
static isInstance<T extends abstract new (...args: never[]) => unknown>(this: T, value: unknown): value is InstanceType<T> {
if (typeof this !== 'function') {
throw new TypeError(
'isInstance must be called on the class (e.g. `SdkError.isInstance(value)`); for callbacks use `v => SdkError.isInstance(v)`'
);
}
return brandedHasInstance(this, value);
}
constructor(
public readonly code: number,
message: string,
@ -18,6 +50,7 @@ export class ProtocolError extends Error {
) {
super(message);
this.name = 'ProtocolError';
stampErrorBrands(this, new.target);
}
/**
@ -86,11 +119,14 @@ export class ProtocolError extends Error {
* accept `-32002` as resource not found — earlier SDK builds emitted that
* code, and {@linkcode ProtocolError.fromError} reconstructs this class for
* either code **when `error.data` carries `uri`** (a bare `-32002` without
* `data.uri` stays a generic {@linkcode ProtocolError}). Do not rely on
* `instanceof` — it does not work across separately bundled copies of the
* SDK.
* `data.uri` stays a generic {@linkcode ProtocolError}). `instanceof` checks
* are brand-matched and work across separately bundled copies of the SDK.
*/
export class ResourceNotFoundError extends ProtocolError {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.ResourceNotFoundError' });
}
constructor(uri: string, message: string = `Resource not found: ${uri}`) {
super(ProtocolErrorCode.InvalidParams, message, { uri });
}
@ -106,6 +142,10 @@ export class ResourceNotFoundError extends ProtocolError {
* This makes it nicer for the client to handle since there is specific data to work with instead of just a code to check against.
*/
export class UrlElicitationRequiredError extends ProtocolError {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.UrlElicitationRequiredError' });
}
constructor(elicitations: ElicitRequestURLParams[], message: string = `URL elicitation${elicitations.length > 1 ? 's' : ''} required`) {
super(ProtocolErrorCode.UrlElicitationRequired, message, {
elicitations: elicitations
@ -127,6 +167,10 @@ export class UrlElicitationRequiredError extends ProtocolError {
* version that was requested (`requested`).
*/
export class UnsupportedProtocolVersionError extends ProtocolError {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.UnsupportedProtocolVersionError' });
}
constructor(data: UnsupportedProtocolVersionErrorData, message: string = `Unsupported protocol version: ${data.requested}`) {
super(ProtocolErrorCode.UnsupportedProtocolVersion, message, data);
}
@ -156,11 +200,15 @@ export class UnsupportedProtocolVersionError extends ProtocolError {
* have to declare for the request to be served. On HTTP, the response status
* is `400 Bad Request`.
*
* Recognize this error by its code and `data.requiredCapabilities` rather than
* by class identity (`instanceof` does not work across separately bundled
* copies of the SDK).
* Recognize this error by its code and `data.requiredCapabilities`, or by
* `instanceof` — checks are brand-matched and work across separately bundled
* copies of the SDK.
*/
export class MissingRequiredClientCapabilityError extends ProtocolError {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.MissingRequiredClientCapabilityError' });
}
constructor(
data: MissingRequiredClientCapabilityErrorData,
message: string = `Missing required client capabilities: ${Object.keys(data.requiredCapabilities).join(', ')}`

View file

@ -0,0 +1,197 @@
import { describe, expect, it, vi } from 'vitest';
import { OAuthError, OAuthErrorCode } from '../../src/auth/errors';
import { SdkError, SdkErrorCode, SdkHttpError } from '../../src/errors/sdkErrors';
import { ProtocolErrorCode } from '../../src/types/enums';
import {
MissingRequiredClientCapabilityError,
ProtocolError,
ResourceNotFoundError,
UnsupportedProtocolVersionError,
UrlElicitationRequiredError
} from '../../src/types/errors';
// `@modelcontextprotocol/client` and `@modelcontextprotocol/server` each bundle their
// own copy of core-internal; `vi.resetModules()` + a dynamic import reproduces that
// dual-copy situation faithfully (separate class objects, shared registry symbol).
async function loadForeignCopies() {
vi.resetModules();
const sdkErrors = await import('../../src/errors/sdkErrors');
const protocolErrors = await import('../../src/types/errors');
const authErrors = await import('../../src/auth/errors');
return { sdkErrors, protocolErrors, authErrors };
}
describe('cross-bundle error instanceof (branded)', () => {
it('same-bundle instanceof keeps working for every class', () => {
const http = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'boom', { status: 404 });
expect(http instanceof SdkHttpError).toBe(true);
expect(http instanceof SdkError).toBe(true);
expect(http instanceof Error).toBe(true);
const proto = new ProtocolError(ProtocolErrorCode.InvalidParams, 'bad');
expect(proto instanceof ProtocolError).toBe(true);
const rnf = new ResourceNotFoundError('file:///missing');
expect(rnf instanceof ResourceNotFoundError).toBe(true);
expect(rnf instanceof ProtocolError).toBe(true);
const oauth = new OAuthError(OAuthErrorCode.InvalidToken, 'nope');
expect(oauth instanceof OAuthError).toBe(true);
});
it('hierarchies stay disjoint', () => {
const sdk = new SdkError(SdkErrorCode.NotConnected, 'x');
const proto = new ProtocolError(ProtocolErrorCode.InvalidParams, 'x');
expect(sdk instanceof ProtocolError).toBe(false);
expect(proto instanceof SdkError).toBe(false);
expect(proto instanceof OAuthError).toBe(false);
const rnf = new ResourceNotFoundError('file:///missing');
expect(rnf instanceof UrlElicitationRequiredError).toBe(false);
expect(rnf instanceof UnsupportedProtocolVersionError).toBe(false);
expect(rnf instanceof MissingRequiredClientCapabilityError).toBe(false);
});
it('an instance from a second module copy satisfies instanceof against this copy (and vice versa)', async () => {
const { sdkErrors, protocolErrors, authErrors } = await loadForeignCopies();
// Sanity: these really are different class objects.
expect(sdkErrors.SdkHttpError).not.toBe(SdkHttpError);
expect(protocolErrors.ProtocolError).not.toBe(ProtocolError);
const foreignHttp = new sdkErrors.SdkHttpError(sdkErrors.SdkErrorCode.ClientHttpAuthentication, '401', { status: 401 });
expect(foreignHttp instanceof SdkHttpError).toBe(true);
expect(foreignHttp instanceof SdkError).toBe(true);
expect(foreignHttp instanceof ProtocolError).toBe(false);
const localHttp = new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, '401', { status: 401 });
expect(localHttp instanceof sdkErrors.SdkHttpError).toBe(true);
expect(localHttp instanceof sdkErrors.SdkError).toBe(true);
const foreignRnf = new protocolErrors.ResourceNotFoundError('file:///missing');
expect(foreignRnf instanceof ResourceNotFoundError).toBe(true);
expect(foreignRnf instanceof ProtocolError).toBe(true);
expect(foreignRnf instanceof UrlElicitationRequiredError).toBe(false);
const foreignPlainProto = new protocolErrors.ProtocolError(ProtocolErrorCode.InvalidParams, 'x');
expect(foreignPlainProto instanceof ProtocolError).toBe(true);
expect(foreignPlainProto instanceof ResourceNotFoundError).toBe(false);
const foreignOauth = new authErrors.OAuthError(authErrors.OAuthErrorCode.InvalidGrant, 'x');
expect(foreignOauth instanceof OAuthError).toBe(true);
expect(foreignOauth instanceof SdkError).toBe(false);
});
it('ProtocolError.fromError from a second copy reconstructs instances this copy recognizes', async () => {
const { protocolErrors } = await loadForeignCopies();
const reconstructed = protocolErrors.ProtocolError.fromError(ProtocolErrorCode.InvalidParams, 'gone', {
uri: 'file:///missing'
});
expect(reconstructed instanceof ResourceNotFoundError).toBe(true);
expect(reconstructed instanceof ProtocolError).toBe(true);
});
it('isInstance guards agree with instanceof, per class, including across module copies', async () => {
const http = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'boom', { status: 404 });
expect(SdkHttpError.isInstance(http)).toBe(true);
expect(SdkError.isInstance(http)).toBe(true);
// inherited guard is scoped to the caller: an SdkError is NOT an SdkHttpError
expect(SdkHttpError.isInstance(new SdkError(SdkErrorCode.ConnectionClosed, 'x'))).toBe(false);
const foreign = await loadForeignCopies();
const foreignHttp = new foreign.sdkErrors.SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'boom', { status: 500 });
expect(SdkHttpError.isInstance(foreignHttp)).toBe(true);
expect(SdkError.isInstance(foreignHttp)).toBe(true);
const foreignRnf = new foreign.protocolErrors.ResourceNotFoundError('file:///missing');
expect(ResourceNotFoundError.isInstance(foreignRnf)).toBe(true);
expect(ProtocolError.isInstance(foreignRnf)).toBe(true);
expect(SdkError.isInstance(foreignRnf)).toBe(false);
// detached calls fail loud instead of silently matching nothing
// (the cast models the callback-position escape TS cannot flag, e.g.
// `errors.filter(SdkError.isInstance)`)
const detached = SdkError.isInstance as (value: unknown) => boolean;
expect(() => detached(new SdkError(SdkErrorCode.ConnectionClosed, 'x'))).toThrow(TypeError);
// agreement with the operator on negatives
for (const cls of [ProtocolError, SdkError, SdkHttpError, OAuthError]) {
for (const v of [new Error('plain'), null, undefined, 42, 'x', {}]) {
expect(cls.isInstance(v)).toBe(v instanceof (cls as never as new () => unknown));
}
}
});
it('user-defined subclasses keep plain prototype semantics (no cross-class false positives)', () => {
class MyError extends ProtocolError {}
const mine = new MyError(ProtocolErrorCode.InternalError, 'mine');
expect(mine instanceof MyError).toBe(true);
expect(mine instanceof ProtocolError).toBe(true);
// A base-class instance — same or foreign bundle — must never satisfy the subclass.
const base = new ProtocolError(ProtocolErrorCode.InternalError, 'base');
expect(base instanceof MyError).toBe(false);
});
it('a foreign base instance does not satisfy a user-defined subclass of this copy', async () => {
const { protocolErrors } = await loadForeignCopies();
class MyError extends ProtocolError {}
const foreignBase = new protocolErrors.ProtocolError(ProtocolErrorCode.InternalError, 'x');
expect(foreignBase instanceof MyError).toBe(false);
});
it('does not leak the brand through enumeration or serialization', () => {
const err = new SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'nope', { status: 403 });
expect(Object.keys(err).some(k => k.includes('mcp'))).toBe(false);
const brandKey = Symbol.for('mcp.sdk.errorBrands');
expect(Object.prototype.propertyIsEnumerable.call(err, brandKey)).toBe(false);
const json = JSON.stringify({ ...err });
expect(json).not.toContain('mcp.Sdk');
});
it('ignores a brand inherited via the prototype chain (prototype-pollution hardening)', () => {
const BRANDS = Symbol.for('mcp.sdk.errorBrands');
// A brand reachable only through the prototype chain must not count —
// otherwise polluting a shared prototype would make arbitrary objects
// satisfy instanceof against every branded class.
const polluted = Object.create({ [BRANDS]: new Set(['mcp.SdkError', 'mcp.SdkHttpError']) }) as object;
expect(polluted instanceof SdkError).toBe(false);
expect(polluted instanceof SdkHttpError).toBe(false);
// An own-property carrier (what stampErrorBrands produces) is honored.
const own = Object.create(Error.prototype) as object;
Object.defineProperty(own, BRANDS, { value: new Set(['mcp.SdkError']), enumerable: false });
expect(own instanceof SdkError).toBe(true);
});
it('matches across versions by identity, not shape (contract lock)', () => {
// Brand strings are version-less by design: an instance constructed by a
// different SDK version cross-matches. instanceof implies identity, not
// field shape - consumers read fields defensively.
const BRANDS = Symbol.for('mcp.sdk.errorBrands');
const olderAlpha = Object.create(Error.prototype) as { status?: number };
Object.defineProperty(olderAlpha, BRANDS, { value: new Set(['mcp.SdkError', 'mcp.SdkHttpError']), enumerable: false });
expect((olderAlpha as unknown) instanceof SdkHttpError).toBe(true);
expect(olderAlpha.status).toBeUndefined();
});
it('does not throw on hostile proxies (falls back to prototype check)', () => {
const hostile = new Proxy(
{},
{
getOwnPropertyDescriptor() {
throw new Error('trap');
}
}
);
expect((hostile as unknown) instanceof SdkError).toBe(false);
});
it('tolerates primitives, null, and brandless objects', () => {
for (const value of [null, undefined, 42, 'x', Symbol('s'), {}, new Error('plain')]) {
expect((value as unknown) instanceof SdkError).toBe(false);
expect((value as unknown) instanceof ProtocolError).toBe(false);
expect((value as unknown) instanceof OAuthError).toBe(false);
}
});
});

View file

@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest';
import type { BaseContext } from '../../src/shared/protocol';
import { Protocol } from '../../src/shared/protocol';
import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors';
import { InMemoryTransport } from '../../src/util/inMemory';
class TestProtocolImpl extends Protocol<BaseContext> {
protected assertCapabilityForMethod(): void {}
protected assertNotificationCapability(): void {}
protected assertRequestHandlerCapability(): void {}
protected buildContext(ctx: BaseContext): BaseContext {
return ctx;
}
}
/**
* Pins the abort-reason behavior the branding changeset announces: an
* `SdkError` used as an abort reason is rethrown as-is — including one
* constructed by a foreign bundled copy of the SDK, which only matches
* `reason instanceof SdkError` through the cross-bundle brand.
*/
describe('request() abort-reason passthrough', () => {
async function connectedProtocol(): Promise<TestProtocolImpl> {
const protocol = new TestProtocolImpl();
const [transport] = InMemoryTransport.createLinkedPair();
await protocol.connect(transport);
return protocol;
}
it('rethrows a same-bundle SdkError abort reason as the same object', async () => {
const protocol = await connectedProtocol();
const reason = new SdkError(SdkErrorCode.ConnectionClosed, 'caller closed');
const controller = new AbortController();
controller.abort(reason);
await expect(protocol.request({ method: 'ping' }, { signal: controller.signal })).rejects.toBe(reason);
});
it('rethrows a foreign-bundle SdkError abort reason as the same object (brand-matched)', async () => {
vi.resetModules();
const foreign = await import('../../src/errors/sdkErrors');
expect(foreign.SdkError).not.toBe(SdkError);
const protocol = await connectedProtocol();
const reason = new foreign.SdkError(foreign.SdkErrorCode.ConnectionClosed, 'foreign caller closed');
const controller = new AbortController();
controller.abort(reason);
await expect(protocol.request({ method: 'ping' }, { signal: controller.signal })).rejects.toBe(reason);
});
it('wraps a non-SdkError abort reason in SdkError(RequestTimeout)', async () => {
const protocol = await connectedProtocol();
const controller = new AbortController();
controller.abort(new Error('plain'));
const rejection = await protocol.request({ method: 'ping' }, { signal: controller.signal }).then(
() => {
throw new Error('request unexpectedly resolved');
},
(e: unknown) => e
);
expect(rejection).toBeInstanceOf(SdkError);
expect((rejection as SdkError).code).toBe(SdkErrorCode.RequestTimeout);
});
});

View file

@ -2,8 +2,9 @@
* Behavior-surface pins: error codes, error classes, and version constants.
*
* Consumers match SDK errors by literal numeric code, `error.name`, and message
* text — not only by enum member or `instanceof` (which breaks across bundled
* package boundaries). These tests pin the literal values so that a renumber,
* text — not only by enum member or `instanceof` (brand-matched `instanceof`
* needs both bundled copies at a brand-aware release; the literal values are
* the version-agnostic contract). These tests pin the literal values so that a renumber,
* rename, or membership change turns CI red instead of landing silently. A
* failing pin here means the change is deliberate: update the pin in the same
* change, together with a changeset and a migration-doc entry.
@ -90,11 +91,34 @@ describe('SdkErrorCode', () => {
});
});
describe('cross-bundle brand strings', () => {
test('pins every core error brand (renaming one severs cross-version matching — must be deliberate)', async () => {
const { OAuthError } = await import('../../src/auth/errors');
const { SdkError, SdkHttpError } = await import('../../src/errors/sdkErrors');
const {
MissingRequiredClientCapabilityError,
ProtocolError: PE,
ResourceNotFoundError,
UnsupportedProtocolVersionError,
UrlElicitationRequiredError
} = await import('../../src/types/errors');
const brand = (cls: unknown): unknown => (cls as { mcpBrand?: string }).mcpBrand;
expect(brand(PE)).toBe('mcp.ProtocolError');
expect(brand(ResourceNotFoundError)).toBe('mcp.ResourceNotFoundError');
expect(brand(UrlElicitationRequiredError)).toBe('mcp.UrlElicitationRequiredError');
expect(brand(UnsupportedProtocolVersionError)).toBe('mcp.UnsupportedProtocolVersionError');
expect(brand(MissingRequiredClientCapabilityError)).toBe('mcp.MissingRequiredClientCapabilityError');
expect(brand(SdkError)).toBe('mcp.SdkError');
expect(brand(SdkHttpError)).toBe('mcp.SdkHttpError');
expect(brand(OAuthError)).toBe('mcp.OAuthError');
});
});
describe('ProtocolError', () => {
test('sets error.name, carries code/data, and leaves the message verbatim', () => {
// Consumers classify errors via err.name (instanceof breaks when core is
// bundled into both the client and server dists), and read .code/.data as
// a duck shape. The constructor must not decorate the message.
// Consumers classify errors via err.name (`instanceof` brand-matches
// across bundles only when both copies are brand-aware), and read
// .code/.data as a duck shape. The constructor must not decorate the message.
const error = new ProtocolError(ProtocolErrorCode.InvalidParams, 'oops', { extra: 1 });
expect(error.name).toBe('ProtocolError');
expect(error.code).toBe(-32602);

View file

@ -2,8 +2,9 @@
* The `-32021` MissingRequiredClientCapability typed error.
*
* Recognition is data-parse based: a peer (or another bundled copy of the SDK)
* is recognized by the error code plus the `data.requiredCapabilities` shape,
* never by `instanceof` across bundles.
* is recognized by the error code plus the `data.requiredCapabilities` shape —
* the version-agnostic path that also covers plain wire shapes and pre-brand
* SDK copies (branded `instanceof` needs both copies brand-aware).
*/
import { describe, expect, test } from 'vitest';

View file

@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import * as barrel from '../../src/index';
/**
* Server-side twin of the client's errorBrandConformance test: every error
* class exported from this package's public surface must carry an own
* `mcpBrand` static and resolve `instanceof` through a branded
* `Symbol.hasInstance`, so a new exported error class cannot silently regress
* to prototype-identity `instanceof` across bundled copies. See
* `core-internal/src/errors/crossBundleBrand.ts` for the participation
* criterion.
*/
/** Error classes exported on purpose without a brand. Justify every entry. */
const UNBRANDED_ALLOWLIST: ReadonlySet<string> = new Set([]);
function exportedErrorClasses(mod: Record<string, unknown>): Array<[string, Function]> {
return Object.entries(mod)
.filter((entry): entry is [string, Function] => {
const v = entry[1];
return typeof v === 'function' && v !== Error && v.prototype instanceof Error;
})
.sort(([a], [b]) => a.localeCompare(b));
}
describe('error brand conformance (server export surface)', () => {
const classes = exportedErrorClasses(barrel as Record<string, unknown>);
it('finds the export surface (guards against a broken walker)', () => {
expect(classes.length).toBeGreaterThanOrEqual(5);
expect(classes.map(([n]) => n)).toContain('ProtocolError');
});
it('every exported error class is branded (or explicitly allowlisted)', () => {
const unbranded = classes
.filter(([name]) => !UNBRANDED_ALLOWLIST.has(name))
.filter(([, cls]) => !Object.prototype.hasOwnProperty.call(cls, 'mcpBrand'))
.map(([name]) => name);
expect(
unbranded,
`unbranded exported error classes (add the static mcpBrand block, or allowlist with a justification): ${unbranded.join(', ')}`
).toEqual([]);
});
it('every branded class resolves instanceof through a branded hasInstance', () => {
const missing = classes
.filter(([, cls]) => Object.prototype.hasOwnProperty.call(cls, 'mcpBrand'))
.filter(([, cls]) => (cls as never as Record<symbol, unknown>)[Symbol.hasInstance] === Function.prototype[Symbol.hasInstance])
.map(([name]) => name);
expect(missing, `branded classes whose hierarchy root does not install brandedHasInstance: ${missing.join(', ')}`).toEqual([]);
});
it('every branded class exposes an isInstance guard that agrees with instanceof', () => {
const branded = classes.filter(([, cls]) => Object.prototype.hasOwnProperty.call(cls, 'mcpBrand'));
const missing = branded.filter(([, cls]) => typeof (cls as { isInstance?: unknown }).isInstance !== 'function').map(([n]) => n);
expect(missing, `branded classes without a static isInstance guard: ${missing.join(', ')}`).toEqual([]);
for (const [, cls] of branded) {
const guard = (cls as unknown as { isInstance: (v: unknown) => boolean }).isInstance;
for (const v of [new Error('plain'), null, undefined, 0, '', {}]) {
expect(guard.call(cls, v)).toBe(v instanceof (cls as never as new () => unknown));
}
}
});
// Core-internal brand strings are pinned in core-internal's
// errorSurfacePins.test.ts. A server-local branded error class must add its
// brand string here so a rename cannot land silently.
const CORE_PINNED = new Set([
'MissingRequiredClientCapabilityError',
'OAuthError',
'ProtocolError',
'ResourceNotFoundError',
'SdkError',
'SdkHttpError',
'UnsupportedProtocolVersionError',
'UrlElicitationRequiredError'
]);
const SERVER_LOCAL_BRANDS: Record<string, string> = {};
it('pins every server-local brand string (add new entries here — renames must be deliberate)', () => {
const brands = Object.fromEntries(
classes
.filter(([name, cls]) => !CORE_PINNED.has(name) && Object.prototype.hasOwnProperty.call(cls, 'mcpBrand'))
.map(([name, cls]) => [name, (cls as never as { mcpBrand: string }).mcpBrand])
);
expect(brands).toEqual(SERVER_LOCAL_BRANDS);
});
});