fix(core): make instanceof on SDK error classes work across bundled copies (#2384)
This commit is contained in:
parent
e6cc5dc633
commit
ce2f65db0e
18 changed files with 1020 additions and 26 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
130
packages/client/test/client/errorBrandConformance.test.ts
Normal file
130
packages/client/test/client/errorBrandConformance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
26
packages/client/test/client/sseErrorBrand.test.ts
Normal file
26
packages/client/test/client/sseErrorBrand.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
117
packages/core-internal/src/errors/crossBundleBrand.ts
Normal file
117
packages/core-internal/src/errors/crossBundleBrand.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './auth/errors';
|
||||
export * from './errors/crossBundleBrand';
|
||||
export * from './errors/sdkErrors';
|
||||
export * from './shared/auth';
|
||||
export * from './shared/authUtils';
|
||||
|
|
|
|||
|
|
@ -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(', ')}`
|
||||
|
|
|
|||
197
packages/core-internal/test/errors/crossBundleBrand.test.ts
Normal file
197
packages/core-internal/test/errors/crossBundleBrand.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
89
packages/server/test/server/errorBrandConformance.test.ts
Normal file
89
packages/server/test/server/errorBrandConformance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue