Skip to content

Commit 368cca3

Browse files
fix(core): address review findings on cross-bundle error branding
- Move brands from declared protected static fields into static blocks: the field declaration reached the .d.mts and TypeScript's protected-member rule made the constructor types nominally incompatible across the two packages (typeof ProtocolError from client was unassignable to server's). Static blocks keep the own runtime property with no type-surface footprint. - Harden brandedHasInstance against hostile Proxy traps and throwing accessors: instanceof must not throw where it previously returned false. - Brand SseError: the migration guide's HTTP-status classification row pairs instanceof SdkHttpError with instanceof SseError; both are now cross-bundle-robust (two client copies in one process). - Lock the cross-version contract in tests (identity, not shape) and document it plus the both-copies requirement in the changeset and module JSDoc.
1 parent 02d279e commit 368cca3

9 files changed

Lines changed: 129 additions & 28 deletions

File tree

.changeset/cross-bundle-error-instanceof.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
'@modelcontextprotocol/server': patch
44
---
55

6-
`instanceof` on the SDK error classes (`ProtocolError` and its typed subclasses, `SdkError`/`SdkHttpError`, `OAuthError`) 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.
6+
`instanceof` on the SDK error classes (`ProtocolError` and its typed subclasses, `SdkError`/`SdkHttpError`, `OAuthError`, and the client's `SseError`) 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`.

packages/client/src/client/sse.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
22
import {
3+
brandedHasInstance,
34
createFetchWithInit,
45
JSONRPCMessageSchema,
56
normalizeHeaders,
67
SdkError,
78
SdkErrorCode,
8-
SdkHttpError
9+
SdkHttpError,
10+
stampErrorBrands
911
} from '@modelcontextprotocol/core-internal';
1012
import type { ErrorEvent, EventSourceInit } from 'eventsource';
1113
import { EventSource } from 'eventsource';
@@ -23,12 +25,21 @@ import {
2325
import type { IssuerMismatchError } from './authErrors';
2426

2527
export class SseError extends Error {
28+
static {
29+
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.SseError' });
30+
}
31+
32+
static override [Symbol.hasInstance](value: unknown): boolean {
33+
return brandedHasInstance(this, value);
34+
}
35+
2636
constructor(
2737
public readonly code: number | undefined,
2838
message: string | undefined,
2939
public readonly event: ErrorEvent
3040
) {
3141
super(`SSE error: ${message}`);
42+
stampErrorBrands(this, new.target);
3243
}
3344
}
3445

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { SseError } from '../../src/client/sse';
4+
5+
describe('SseError cross-bundle instanceof', () => {
6+
it('same-bundle instanceof and fields keep working', () => {
7+
const err = new SseError(401, 'unauthorized', {} as never);
8+
expect(err instanceof SseError).toBe(true);
9+
expect(err instanceof Error).toBe(true);
10+
expect(err.code).toBe(401);
11+
});
12+
13+
it('an instance from a second module copy satisfies instanceof against this copy', async () => {
14+
vi.resetModules();
15+
const copy2 = await import('../../src/client/sse');
16+
expect(copy2.SseError).not.toBe(SseError);
17+
const foreign = new copy2.SseError(403, 'forbidden', {} as never);
18+
expect(foreign instanceof SseError).toBe(true);
19+
});
20+
21+
it('stays disjoint from the SdkError hierarchy', async () => {
22+
const { SdkError } = await import('@modelcontextprotocol/core-internal');
23+
const err = new SseError(500, 'boom', {} as never);
24+
expect((err as unknown) instanceof SdkError).toBe(false);
25+
});
26+
});

packages/core-internal/src/auth/errors.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ export enum OAuthErrorCode {
104104
* OAuth error class for all OAuth-related errors.
105105
*/
106106
export class OAuthError extends Error {
107-
protected static readonly mcpBrand: string = 'mcp.OAuthError';
107+
static {
108+
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.OAuthError' });
109+
}
108110

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

packages/core-internal/src/errors/crossBundleBrand.ts

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,24 @@
1212
* brand set. Ordinary prototype-based `instanceof` is kept as a fallback so behavior
1313
* is unchanged for anything unbranded.
1414
*
15-
* A class participates by declaring an **own** `static readonly mcpBrand` and (for
16-
* hierarchy roots) installing {@linkcode brandedHasInstance} as `Symbol.hasInstance`.
17-
* User-defined subclasses that do not declare their own brand keep plain prototype
18-
* semantics — a foreign base-class instance never satisfies `instanceof UserSubclass`.
15+
* A class participates by defining an **own** `mcpBrand` static (via a `static {}`
16+
* block, so nothing reaches the declaration files — a declared `protected static`
17+
* field would make the constructor types nominally incompatible across the bundled
18+
* copies) and (for hierarchy roots) installing {@linkcode brandedHasInstance} as
19+
* `Symbol.hasInstance`. User-defined subclasses that do not declare their own brand
20+
* keep plain prototype semantics — a foreign base-class instance never satisfies
21+
* `instanceof UserSubclass`.
22+
*
23+
* Contract notes:
24+
* - Brands assert **identity, not shape**: brand strings are version-less, so an
25+
* instance from one SDK version matches the class of another. Members added to a
26+
* branded class in a later version may be absent on a matched instance — read
27+
* fields defensively, and treat branded classes as additive-only.
28+
* - Cross-bundle matching requires **both** copies to be at or after the release
29+
* that introduced branding; against an older copy, behavior degrades to plain
30+
* prototype `instanceof` in both directions.
31+
* - A consumer re-bundling the SDK with property mangling (`mangle.props`) would
32+
* break the brand statics; default esbuild/webpack/terser settings do not.
1933
*/
2034

2135
/** Registry symbol — identical across bundled copies and realms. */
@@ -33,6 +47,10 @@ interface BrandedConstructor {
3347
* Stamp `instance` with the brand of every class in `ctor`'s chain that declares an
3448
* own `mcpBrand`. Call once from the hierarchy root's constructor with `new.target` —
3549
* subclasses inherit the stamping without touching their constructors.
50+
*
51+
* Constructor-time only: never stamp arbitrary objects. A stamped non-instance would
52+
* satisfy `instanceof` while lacking the prototype members (getters like `.status`)
53+
* that callers reach for after the check.
3654
*/
3755
export function stampErrorBrands(instance: object, ctor: unknown): void {
3856
const brands = new Set<string>();
@@ -54,21 +72,27 @@ export function stampErrorBrands(instance: object, ctor: unknown): void {
5472
* path), falling back to ordinary prototype-based `instanceof` otherwise.
5573
*/
5674
export function brandedHasInstance(cls: object, value: unknown): boolean {
57-
if (
58-
typeof value === 'object' &&
59-
value !== null &&
60-
Object.prototype.hasOwnProperty.call(cls, 'mcpBrand') &&
61-
typeof (cls as BrandedConstructor).mcpBrand === 'string' &&
62-
// Own-property only: a brand inherited via the prototype chain is never
63-
// honored, so polluting Object.prototype with the registry symbol cannot
64-
// make arbitrary objects satisfy instanceof (real instances are stamped
65-
// with an own property by stampErrorBrands).
66-
Object.prototype.hasOwnProperty.call(value, BRANDS)
67-
) {
68-
const carried = (value as BrandCarrier)[BRANDS];
69-
if (carried && typeof carried.has === 'function' && carried.has((cls as BrandedConstructor).mcpBrand!)) {
70-
return true;
75+
try {
76+
if (
77+
typeof value === 'object' &&
78+
value !== null &&
79+
Object.prototype.hasOwnProperty.call(cls, 'mcpBrand') &&
80+
typeof (cls as BrandedConstructor).mcpBrand === 'string' &&
81+
// Own-property only: a brand inherited via the prototype chain is never
82+
// honored, so polluting Object.prototype with the registry symbol cannot
83+
// make arbitrary objects satisfy instanceof (real instances are stamped
84+
// with an own property by stampErrorBrands).
85+
Object.prototype.hasOwnProperty.call(value, BRANDS)
86+
) {
87+
const carried = (value as BrandCarrier)[BRANDS];
88+
if (carried && typeof carried.has === 'function' && carried.has((cls as BrandedConstructor).mcpBrand!)) {
89+
return true;
90+
}
7191
}
92+
} catch {
93+
// A hostile Proxy trap or throwing accessor must not make `instanceof`
94+
// throw where it previously returned false — fall through to the
95+
// ordinary prototype check.
7296
}
7397
return Function.prototype[Symbol.hasInstance].call(cls, value);
7498
}

packages/core-internal/src/errors/sdkErrors.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ export enum SdkErrorCode {
100100
* ```
101101
*/
102102
export class SdkError extends Error {
103-
protected static readonly mcpBrand: string = 'mcp.SdkError';
103+
static {
104+
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.SdkError' });
105+
}
104106

105107
static override [Symbol.hasInstance](value: unknown): boolean {
106108
return brandedHasInstance(this, value);
@@ -143,7 +145,9 @@ export interface SdkHttpErrorData {
143145
* ```
144146
*/
145147
export class SdkHttpError extends SdkError {
146-
protected static override readonly mcpBrand: string = 'mcp.SdkHttpError';
148+
static {
149+
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.SdkHttpError' });
150+
}
147151

148152
declare readonly data: SdkHttpErrorData;
149153

packages/core-internal/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './auth/errors';
2+
export * from './errors/crossBundleBrand';
23
export * from './errors/sdkErrors';
34
export * from './shared/auth';
45
export * from './shared/authUtils';

packages/core-internal/src/types/errors.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import type {
1717
* `@modelcontextprotocol/server` in the same process.
1818
*/
1919
export class ProtocolError extends Error {
20-
protected static readonly mcpBrand: string = 'mcp.ProtocolError';
20+
static {
21+
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.ProtocolError' });
22+
}
2123

2224
static override [Symbol.hasInstance](value: unknown): boolean {
2325
return brandedHasInstance(this, value);
@@ -103,7 +105,9 @@ export class ProtocolError extends Error {
103105
* are brand-matched and work across separately bundled copies of the SDK.
104106
*/
105107
export class ResourceNotFoundError extends ProtocolError {
106-
protected static override readonly mcpBrand: string = 'mcp.ResourceNotFoundError';
108+
static {
109+
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.ResourceNotFoundError' });
110+
}
107111

108112
constructor(uri: string, message: string = `Resource not found: ${uri}`) {
109113
super(ProtocolErrorCode.InvalidParams, message, { uri });
@@ -120,7 +124,9 @@ export class ResourceNotFoundError extends ProtocolError {
120124
* 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.
121125
*/
122126
export class UrlElicitationRequiredError extends ProtocolError {
123-
protected static override readonly mcpBrand: string = 'mcp.UrlElicitationRequiredError';
127+
static {
128+
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.UrlElicitationRequiredError' });
129+
}
124130

125131
constructor(elicitations: ElicitRequestURLParams[], message: string = `URL elicitation${elicitations.length > 1 ? 's' : ''} required`) {
126132
super(ProtocolErrorCode.UrlElicitationRequired, message, {
@@ -143,7 +149,9 @@ export class UrlElicitationRequiredError extends ProtocolError {
143149
* version that was requested (`requested`).
144150
*/
145151
export class UnsupportedProtocolVersionError extends ProtocolError {
146-
protected static override readonly mcpBrand: string = 'mcp.UnsupportedProtocolVersionError';
152+
static {
153+
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.UnsupportedProtocolVersionError' });
154+
}
147155

148156
constructor(data: UnsupportedProtocolVersionErrorData, message: string = `Unsupported protocol version: ${data.requested}`) {
149157
super(ProtocolErrorCode.UnsupportedProtocolVersion, message, data);
@@ -179,7 +187,9 @@ export class UnsupportedProtocolVersionError extends ProtocolError {
179187
* copies of the SDK.
180188
*/
181189
export class MissingRequiredClientCapabilityError extends ProtocolError {
182-
protected static override readonly mcpBrand: string = 'mcp.MissingRequiredClientCapabilityError';
190+
static {
191+
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.MissingRequiredClientCapabilityError' });
192+
}
183193

184194
constructor(
185195
data: MissingRequiredClientCapabilityErrorData,

packages/core-internal/test/errors/crossBundleBrand.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,29 @@ describe('cross-bundle error instanceof (branded)', () => {
132132
expect(own instanceof SdkError).toBe(true);
133133
});
134134

135+
it('matches across versions by identity, not shape (contract lock)', () => {
136+
// Brand strings are version-less by design: an instance constructed by a
137+
// different SDK version cross-matches. instanceof implies identity, not
138+
// field shape - consumers read fields defensively.
139+
const BRANDS = Symbol.for('mcp.sdk.errorBrands');
140+
const olderAlpha = Object.create(Error.prototype) as { status?: number };
141+
Object.defineProperty(olderAlpha, BRANDS, { value: new Set(['mcp.SdkError', 'mcp.SdkHttpError']), enumerable: false });
142+
expect((olderAlpha as unknown) instanceof SdkHttpError).toBe(true);
143+
expect(olderAlpha.status).toBeUndefined();
144+
});
145+
146+
it('does not throw on hostile proxies (falls back to prototype check)', () => {
147+
const hostile = new Proxy(
148+
{},
149+
{
150+
getOwnPropertyDescriptor() {
151+
throw new Error('trap');
152+
}
153+
}
154+
);
155+
expect((hostile as unknown) instanceof SdkError).toBe(false);
156+
});
157+
135158
it('tolerates primitives, null, and brandless objects', () => {
136159
for (const value of [null, undefined, 42, 'x', Symbol('s'), {}, new Error('plain')]) {
137160
expect((value as unknown) instanceof SdkError).toBe(false);

0 commit comments

Comments
 (0)