fix(client): treat HTTP 401/403 on the negotiation probe as auth failures, not legacy evidence (#2564)

This commit is contained in:
Felix Weinberger 2026-07-28 00:41:02 +01:00 • committed by GitHub
commit faa7e2b470
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 909 additions and 91 deletions

View file

@ -0,0 +1,40 @@
/**
* Auth-seam provenance stamp (internal — not part of the public API).
*
* Errors escaping a transport auth seam — the `authProvider.token()` read,
* an `onUnauthorized` invocation (SDK adapter or custom user callback), the
* 403 step-up flow, the transport's own auth-failure constructions — are
* stamped at the throw boundary. The version-negotiation probe routes stamped
* errors as auth outcomes; provenance is recorded where it is known, never
* reconstructed from error types downstream.
*
* `Symbol.for` uses the global symbol registry, so the stamp survives a
* duplicated SDK copy in one process (bundler double-install, version skew)
* by design: both copies resolve the same symbol.
*/
const AUTH_SEAM = Symbol.for('mcp.authSeamEscape');
/**
* Stamp `error` as an auth-seam escape and return it — identity-preserving
* (the same object flows on, `instanceof` and `.cause` chains intact). A
* frozen/sealed object is returned unstamped rather than replaced: identity
* outranks provenance. Primitive throws cannot carry the stamp.
*/
export function markAuthSeamEscape<T>(error: T): T {
if ((typeof error === 'object' && error !== null) || typeof error === 'function') {
try {
Object.defineProperty(error, AUTH_SEAM, { value: true, configurable: true });
} catch {
// Frozen/sealed: leave unstamped.
}
}
return error;
}
/** Whether `error` escaped through a transport auth seam. */
export function isAuthSeamEscape(error: unknown): boolean {
return (
((typeof error === 'object' && error !== null) || typeof error === 'function') &&
(error as Record<PropertyKey, unknown>)[AUTH_SEAM] === true
);
}

View file

@ -7,8 +7,9 @@
* sibling), or a typed connect error.
*
* The classifier is deliberately conservative: anything it does not positively
* recognize as modern resolves to the legacy fallback, and a network outage is a
* typed connect error, never an era verdict. The verdicts apply to the
* recognize as modern resolves to the legacy fallback, and a network outage or
* an auth-status rejection (HTTP 401/403) is a typed connect error, never an
* era verdict. The verdicts apply to the
* negotiation phase only — an established modern connection is never silently
* demoted to `initialize` by a later failure.
*/
@ -19,6 +20,7 @@ import {
modernProtocolVersions,
SdkError,
SdkErrorCode,
SdkHttpError,
UnsupportedProtocolVersionError
} from '@modelcontextprotocol/core-internal';
@ -46,10 +48,10 @@ export type ProbeOutcome =
| { kind: 'result'; result: unknown }
/** Answered with a JSON-RPC error (any HTTP status, including 200-bodied errors and stdio in-band errors). */
| { kind: 'rpc-error'; code: number; message: string; data?: unknown }
/** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text, when available. */
| { kind: 'http-error'; status: number; body?: string }
/** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text and `statusText` the HTTP reason phrase, when available. */
| { kind: 'http-error'; status: number; body?: string; statusText?: string }
| { kind: 'network-error'; error: unknown }
/** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */
/** The transport's auth flow challenged or failed during the probe send — an error stamped at a transport auth seam, or an `UnauthorizedError` (the foreign-transport contract). `error` propagates unchanged. */
| { kind: 'auth-required'; error: Error }
/** The transport reported close while the probe awaited its reply. */
| { kind: 'closed' }
@ -241,21 +243,70 @@ function classifyRpcError(outcome: { code: number; message: string; data?: unkno
return { kind: 'legacy' };
}
function classifyHttpError(outcome: { status: number; body?: string }, context: ProbeClassifierContext): ProbeVerdict {
function classifyHttpError(outcome: { status: number; body?: string; statusText?: string }, context: ProbeClassifierContext): ProbeVerdict {
// Auth statuses are never era evidence, and a 401/403 body is the auth
// layer's, not the server/discover handler's — this row ranks above the
// JSON-RPC body parse. Parallel to the auth-required row: a typed failure
// naming the status (response text on `data.text`), never a legacy
// fallback. The codes are the auth ones, NOT EraNegotiationFailed: hosts
// key era-recovery recipes (e.g. the gateway guide's cached-verdict flow)
// on EraNegotiationFailed, and an auth wall must never enter era recovery
// — persisting a legacy verdict for it would recreate the silent-legacy
// bug at the fleet level.
if (outcome.status === 401 || outcome.status === 403) {
const isDenial = outcome.status === 403;
return {
kind: 'error',
error: new SdkHttpError(
isDenial ? SdkErrorCode.ClientHttpForbidden : SdkErrorCode.ClientHttpAuthentication,
`Version negotiation failed: ${isDenial ? 'the server denied access (HTTP 403)' : 'the server requires authorization (HTTP 401)'}`,
{
status: outcome.status,
statusText: outcome.statusText,
text: outcome.body
}
)
};
}
if (outcome.status >= 500) {
// A server failure is not era evidence, whatever the body says — this
// row too ranks above the JSON-RPC body parse (a 5xx body is the
// infrastructure's: a mid-deploy gateway's JSON error page, a crashed
// handler's -32603, neither a server/discover verdict). The spec keys
// the HTTP legacy signal to client-error rejections (a 2025 server
// answers the unknown probe 4xx), never to 5xx.
return {
kind: 'error',
error: new SdkHttpError(
SdkErrorCode.EraNegotiationFailed,
`Version negotiation failed: the server answered the probe with HTTP ${outcome.status}`,
{
status: outcome.status,
statusText: outcome.statusText,
text: outcome.body
}
)
};
}
// HTTP-rejected probes carry their JSON-RPC error in the response body — classify it like an in-band error.
const rpcError = parseJsonRpcErrorBody(outcome.body);
if (rpcError !== undefined) {
return classifyRpcError(rpcError, context);
}
// Unparseable or unrecognized HTTP rejection: conservative legacy fallback.
// Unparseable or unrecognized 4xx rejection: conservative legacy fallback.
// With the auth and 5xx rows above, this row's legacy set now equals the
// spec-licensed set exactly — the 4xx a deployed 2025 server answers a
// request it does not recognize.
return { kind: 'legacy' };
}
function classifyNetworkError(error: unknown, context: ProbeClassifierContext): ProbeVerdict {
if (context.environment === 'browser' && isOpaqueFetchTypeError(error)) {
// A browser CORS-preflight rejection against a deployed 2025 server is an
// opaque TypeError; the legacy fallback carries no custom headers (no
// preflight), so it can proceed where the probe could not.
// A browser CORS-preflight rejection against a deployed 2025 server is
// an opaque TypeError. The legacy fallback also preflights (its JSON
// Content-Type is non-simple), but carries only the pre-2026 headers a
// deployed server's CORS allowlist already covers — so it can proceed
// where the probe's 2026 headers could not.
return { kind: 'legacy' };
}
return {

View file

@ -23,6 +23,7 @@ import {
} from './auth';
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode}
import type { IssuerMismatchError } from './authErrors';
import { markAuthSeamEscape } from './authSeam';
export class SseError extends Error {
static {
@ -160,7 +161,14 @@ export class SSEClientTransport implements Transport {
private async _commonHeaders(): Promise<Headers> {
const headers: RequestInit['headers'] & Record<string, string> = {};
const token = await this._authProvider?.token();
let token: string | undefined;
try {
token = await this._authProvider?.token();
} catch (error) {
// Auth-seam stamp: a throwing token() is an auth failure, never a
// network failure.
throw markAuthSeamEscape(error);
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
@ -212,15 +220,18 @@ export class SSEClientTransport implements Transport {
this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then(
// onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject.
() => this._startOrAuth().then(resolve, reject),
// onUnauthorized failed → not yet reported.
error => {
this.onerror?.(error);
// onUnauthorized failed → not yet reported. Auth-seam
// stamp: covers the SDK's OAuth flow and custom
// callbacks alike.
(error: unknown) => {
markAuthSeamEscape(error);
this.onerror?.(error as Error);
reject(error);
}
);
return;
}
const error = new UnauthorizedError();
const error = markAuthSeamEscape(new UnauthorizedError());
reject(error);
this.onerror?.(error);
return;
@ -362,23 +373,31 @@ export class SSEClientTransport implements Transport {
}
if (this._authProvider.onUnauthorized && !isAuthRetry) {
await this._authProvider.onUnauthorized({
response,
serverUrl: this._url,
fetchFn: this._fetchWithInit
});
try {
await this._authProvider.onUnauthorized({
response,
serverUrl: this._url,
fetchFn: this._fetchWithInit
});
} catch (error) {
// Auth-seam stamp: covers the SDK's OAuth flow and
// custom onUnauthorized callbacks alike.
throw markAuthSeamEscape(error);
}
await response.text?.().catch(() => {});
// Purposely _not_ awaited, so we don't call onerror twice
return this._send(message, true);
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401,
statusText: response.statusText
});
throw markAuthSeamEscape(
new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401,
statusText: response.statusText
})
);
}
throw new UnauthorizedError();
throw markAuthSeamEscape(new UnauthorizedError());
}
const text = await response.text?.().catch(() => null);

View file

@ -34,10 +34,14 @@ import {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced via {@linkcode} in finishAuth JSDoc
import type { IssuerMismatchError } from './authErrors';
import { InsufficientScopeError } from './authErrors';
import { markAuthSeamEscape } from './authSeam';
/** Default cap on step-up re-authorization retries within a single send/stream-open. */
const DEFAULT_MAX_STEP_UP_RETRIES = 1;
/** The parsed 403 `insufficient_scope` challenge handed to the step-up flow. */
type StepUpChallenge = { scope?: string; resourceMetadataUrl?: URL; errorDescription?: string; statusText?: string; text?: string | null };
// Default reconnection options for StreamableHTTP connections
const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = {
initialReconnectionDelay: 1000,
@ -367,10 +371,18 @@ export class StreamableHTTPClientTransport implements Transport {
* `_startOrAuthSse` path so both apply the same `'throw'` short-circuit,
* the same superset-gated refresh bypass, and the same retry cap.
*/
private async _stepUpAuthorize(
challenge: { scope?: string; resourceMetadataUrl?: URL; errorDescription?: string; statusText?: string; text?: string | null },
stepUpRetries: number
): Promise<'AUTHORIZED' | 'REDIRECT'> {
private async _stepUpAuthorize(challenge: StepUpChallenge, stepUpRetries: number): Promise<'AUTHORIZED' | 'REDIRECT'> {
// Auth-seam stamp, method-level: covers the InsufficientScopeError
// throws, the retry-limit SdkHttpError, the tokens() read, and every
// auth() escape (typed or not).
try {
return await this._stepUpAuthorizeInner(challenge, stepUpRetries);
} catch (error) {
throw markAuthSeamEscape(error);
}
}
private async _stepUpAuthorizeInner(challenge: StepUpChallenge, stepUpRetries: number): Promise<'AUTHORIZED' | 'REDIRECT'> {
if (this._onInsufficientScope === 'throw') {
throw new InsufficientScopeError({
requiredScope: challenge.scope,
@ -422,7 +434,14 @@ export class StreamableHTTPClientTransport implements Transport {
private async _commonHeaders(): Promise<Headers> {
const headers: RequestInit['headers'] & Record<string, string> = {};
const token = await this._authProvider?.token();
let token: string | undefined;
try {
token = await this._authProvider?.token();
} catch (error) {
// Auth-seam stamp: a throwing token() is an auth failure, never a
// network failure.
throw markAuthSeamEscape(error);
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
@ -543,23 +562,31 @@ export class StreamableHTTPClientTransport implements Transport {
}
if (this._authProvider.onUnauthorized && !isAuthRetry) {
await this._authProvider.onUnauthorized({
response,
serverUrl: this._url,
fetchFn: this._fetchWithInit
});
try {
await this._authProvider.onUnauthorized({
response,
serverUrl: this._url,
fetchFn: this._fetchWithInit
});
} catch (error) {
// Auth-seam stamp: covers the SDK's OAuth flow and
// custom onUnauthorized callbacks alike.
throw markAuthSeamEscape(error);
}
await response.text?.().catch(() => {});
// Purposely _not_ awaited, so we don't call onerror twice
return this._startOrAuthSse(options, true, stepUpRetries);
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401,
statusText: response.statusText
});
throw markAuthSeamEscape(
new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401,
statusText: response.statusText
})
);
}
throw new UnauthorizedError();
throw markAuthSeamEscape(new UnauthorizedError());
}
if (response.status === 403) {
@ -571,7 +598,7 @@ export class StreamableHTTPClientTransport implements Transport {
stepUpRetries
);
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError();
throw markAuthSeamEscape(new UnauthorizedError());
}
return this._startOrAuthSse(options, isAuthRetry, stepUpRetries + 1);
}
@ -997,23 +1024,31 @@ export class StreamableHTTPClientTransport implements Transport {
}
if (this._authProvider.onUnauthorized && !isAuthRetry) {
await this._authProvider.onUnauthorized({
response,
serverUrl: this._url,
fetchFn: this._fetchWithInit
});
try {
await this._authProvider.onUnauthorized({
response,
serverUrl: this._url,
fetchFn: this._fetchWithInit
});
} catch (error) {
// Auth-seam stamp: covers the SDK's OAuth flow and
// custom onUnauthorized callbacks alike.
throw markAuthSeamEscape(error);
}
await response.text?.().catch(() => {});
// Purposely _not_ awaited, so we don't call onerror twice
return this._send(message, options, true, stepUpRetries);
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401,
statusText: response.statusText
});
throw markAuthSeamEscape(
new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401,
statusText: response.statusText
})
);
}
throw new UnauthorizedError();
throw markAuthSeamEscape(new UnauthorizedError());
}
const text = await response.text?.().catch(() => null);
@ -1027,7 +1062,7 @@ export class StreamableHTTPClientTransport implements Transport {
stepUpRetries
);
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError();
throw markAuthSeamEscape(new UnauthorizedError());
}
return this._send(message, options, isAuthRetry, stepUpRetries + 1);
}

View file

@ -26,6 +26,7 @@ import {
} from '@modelcontextprotocol/core-internal';
import { UnauthorizedError } from './auth';
import { isAuthSeamEscape } from './authSeam';
import type { ProbeEnvironment, ProbeOutcome, ProbeTransportKind, ProbeVerdict } from './probeClassifier';
import { classifyProbeOutcome } from './probeClassifier';
@ -381,22 +382,32 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome {
}
case 'send-error': {
const error = reply.error;
const isAuthOutcome =
// Provenance recorded at the throw boundary: the transport
// stamps every error escaping one of its auth seams (the
// token() read, onUnauthorized invocations — SDK flow and
// custom callbacks alike — the 403 step-up method, and its
// own auth-failure constructions). Never reconstructed here
// from error types.
isAuthSeamEscape(error) ||
// The published contract for foreign transports: an
// UnauthorizedError by brand, or by name for one thrown by a
// differently bundled SDK copy at a skewed version or an auth
// middleware's own class.
error instanceof UnauthorizedError ||
(error instanceof Error && error.name === 'UnauthorizedError');
if (isAuthOutcome) {
// Auth-gated server: propagate unchanged.
return { kind: 'auth-required', error: error as Error };
}
if (error instanceof SdkHttpError) {
const text = (error.data as { text?: unknown } | undefined)?.text;
return { kind: 'http-error', status: error.data.status, body: typeof text === 'string' ? text : undefined };
}
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: 'http-error',
status: error.data.status,
body: typeof text === 'string' ? text : undefined,
statusText: error.data.statusText
};
}
return { kind: 'network-error', error };
}

View file

@ -0,0 +1,288 @@
/**
* Fault injection per transport auth seam, adapted from the PR #2564
* neutral-review measurement harness: each scenario drives negotiateEra()
* with a REAL StreamableHTTPClientTransport and a mocked fetch, so the full
* transport 401/403 handling and auth() flow execute for real.
*
* The invariant under test: any error escaping a stamped auth seam reaches
* the caller IDENTITY-PRESERVED (same object; instanceof and diagnostics
* intact) as an auth outcome — never a legacy verdict (the probe's browser
* CORS row must not consume auth-flow TypeErrors), never a rewrap. Errors
* thrown outside the stamped seams keep their pre-existing behavior.
*/
import { OAuthError, SdkError, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal';
import { describe, expect, test } from 'vitest';
import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth';
import { UnauthorizedError } from '../../src/client/auth';
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp';
import { negotiateEra } from '../../src/client/versionNegotiation';
const SERVER = 'https://server.example.com/mcp';
const AS = 'https://auth.example.com';
const AS_METADATA = {
issuer: AS,
authorization_endpoint: `${AS}/authorize`,
token_endpoint: `${AS}/token`,
registration_endpoint: `${AS}/register`,
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
};
function freshProvider(): OAuthClientProvider {
return {
get redirectUrl() {
return 'http://localhost:3000/callback';
},
get clientMetadata() {
return { redirect_uris: ['http://localhost:3000/callback'] };
},
clientInformation: () => undefined,
saveClientInformation: () => {},
tokens: () => undefined,
saveTokens: () => {},
redirectToAuthorization: () => {},
saveCodeVerifier: () => {},
codeVerifier: () => 'verifier'
};
}
/** fetch mock: 401 wall on the MCP endpoint, real discovery, DCR delegated. */
function authWallFetch(onRegister: () => Promise<Response>): typeof fetch {
return (async (input: string | URL | Request, _init?: RequestInit): Promise<Response> => {
const url = new URL(String(input instanceof Request ? input.url : input));
if (url.href === SERVER || url.pathname === '/mcp') {
return new Response('Unauthorized', {
status: 401,
headers: {
'WWW-Authenticate': `Bearer resource_metadata="https://server.example.com/.well-known/oauth-protected-resource/mcp"`
}
});
}
if (url.pathname.includes('oauth-protected-resource')) {
return Response.json({ resource: SERVER, authorization_servers: [AS] });
}
if (url.pathname.includes('oauth-authorization-server') || url.pathname.includes('openid-configuration')) {
return Response.json(AS_METADATA);
}
if (url.pathname === '/register') {
return onRegister();
}
return new Response('Not Found', { status: 404 });
}) as typeof fetch;
}
async function runProbe(transport: StreamableHTTPClientTransport, environment: 'node' | 'browser') {
return negotiateEra(
{ kind: 'auto', modernVersions: ['2026-07-28'], fallbackAvailable: true, probe: {} },
{
transport,
clientInfo: { name: 'seam-test', version: '0' },
capabilities: {},
environment,
transportKind: 'http',
defaultTimeoutMs: 3000
}
).then(
result => ({ settled: 'resolved' as const, result, error: undefined as unknown }),
(error: unknown) => ({ settled: 'rejected' as const, result: undefined, error })
);
}
describe('stamped-seam fault injection (identity-preserving auth outcomes, never legacy)', () => {
test('DCR POST throws TypeError inside the SDK auth flow — browser: the raw TypeError propagates, no CORS-legacy verdict', async () => {
const dcrError = new TypeError('Failed to fetch');
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
authProvider: freshProvider(),
fetch: authWallFetch(() => Promise.reject(dcrError))
});
const out = await runProbe(transport, 'browser');
expect(out.settled).toBe('rejected');
expect(out.error).toBe(dcrError);
});
test('DCR POST throws TypeError — node: same raw propagation, no network-error rewrap', async () => {
const dcrError = new TypeError('Failed to fetch');
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
authProvider: freshProvider(),
fetch: authWallFetch(() => Promise.reject(dcrError))
});
const out = await runProbe(transport, 'node');
expect(out.settled).toBe('rejected');
expect(out.error).toBe(dcrError);
});
test("401 persists after re-auth: the transport's ClientHttpAuthentication diagnostic reaches the caller intact", async () => {
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
authProvider: {
token: async () => 'stale-token',
onUnauthorized: async () => {}
},
fetch: authWallFetch(() => Promise.resolve(new Response(null, { status: 500 })))
});
const out = await runProbe(transport, 'node');
expect(out.settled).toBe('rejected');
expect(out.error).toBeInstanceOf(SdkHttpError);
expect((out.error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
expect((out.error as Error).message).toContain('after re-authentication');
});
test('403 insufficient_scope with no provider to drive step-up: the raw InsufficientScopeError propagates', async () => {
const fetchMock = (async (input: string | URL | Request): Promise<Response> => {
const url = new URL(String(input instanceof Request ? input.url : input));
if (url.href === SERVER || url.pathname === '/mcp') {
return new Response('Forbidden', {
status: 403,
headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="mcp:tools"' }
});
}
return new Response('Not Found', { status: 404 });
}) as typeof fetch;
const transport = new StreamableHTTPClientTransport(new URL(SERVER), { fetch: fetchMock });
const out = await runProbe(transport, 'node');
expect(out.settled).toBe('rejected');
expect((out.error as Error).name).toBe('InsufficientScopeError');
expect((out.error as Error).message).toContain('mcp:tools');
});
test('user callback (clientInformation) throws a custom class inside auth(): the raw instance propagates — instanceof works', async () => {
class UserDbError extends Error {
override readonly name = 'UserDbError';
}
const userError = new UserDbError('user db exploded');
const provider = freshProvider();
provider.clientInformation = () => {
throw userError;
};
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
authProvider: provider,
fetch: authWallFetch(() => Promise.resolve(new Response(null, { status: 500 })))
});
const out = await runProbe(transport, 'node');
expect(out.settled).toBe('rejected');
expect(out.error).toBe(userError);
expect(out.error).toBeInstanceOf(UserDbError);
});
test('token endpoint rejects with a non-recoverable OAuthError: the raw OAuthError (code intact) propagates', async () => {
const fetchMock = (async (input: string | URL | Request): Promise<Response> => {
const url = new URL(String(input instanceof Request ? input.url : input));
if (url.href === SERVER || url.pathname === '/mcp') {
return new Response('Unauthorized', {
status: 401,
headers: {
'WWW-Authenticate': `Bearer resource_metadata="https://server.example.com/.well-known/oauth-protected-resource/mcp"`
}
});
}
if (url.pathname.includes('oauth-protected-resource')) {
return Response.json({ resource: SERVER, authorization_servers: [AS] });
}
if (url.pathname.includes('oauth-authorization-server') || url.pathname.includes('openid-configuration')) {
return Response.json(AS_METADATA);
}
if (url.pathname === '/token') {
return Response.json({ error: 'invalid_scope', error_description: 'scope not granted' }, { status: 400 });
}
return new Response('Not Found', { status: 404 });
}) as typeof fetch;
const provider = freshProvider();
provider.clientInformation = () => ({ client_id: 'abc', issuer: AS });
provider.tokens = () => ({ access_token: 'tok', refresh_token: 'rt', token_type: 'Bearer', issuer: AS });
const transport = new StreamableHTTPClientTransport(new URL(SERVER), { authProvider: provider, fetch: fetchMock });
const out = await runProbe(transport, 'node');
expect(out.settled).toBe('rejected');
expect(out.error).toBeInstanceOf(OAuthError);
expect((out.error as OAuthError).code).toBe('invalid_scope');
});
test('healthy flow ending in REDIRECT: UnauthorizedError propagates (the finishAuth contract)', async () => {
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
authProvider: freshProvider(),
fetch: authWallFetch(() =>
Promise.resolve(Response.json({ client_id: 'abc', redirect_uris: ['http://localhost:3000/callback'] }, { status: 201 }))
)
});
const out = await runProbe(transport, 'node');
expect(out.settled).toBe('rejected');
expect(out.error).toBeInstanceOf(UnauthorizedError);
});
test('R6: token() throws at the _commonHeaders read — browser: raw TypeError propagates, never the CORS-legacy verdict', async () => {
const tokenError = new TypeError("Cannot read properties of undefined (reading 'access_token')");
const provider: AuthProvider = {
token: () => {
throw tokenError;
}
};
let fetched = 0;
const fetchMock = (async (): Promise<Response> => {
fetched++;
return new Response(null, { status: 500 });
}) as typeof fetch;
const transport = new StreamableHTTPClientTransport(new URL(SERVER), { authProvider: provider, fetch: fetchMock });
const out = await runProbe(transport, 'browser');
expect(out.settled).toBe('rejected');
expect(out.error).toBe(tokenError);
// The failure happened before any network I/O — nothing was fetched,
// and in particular no legacy initialize followed.
expect(fetched).toBe(0);
});
test('B12: a CUSTOM onUnauthorized callback throws TypeError — browser: raw propagation, never the CORS-legacy verdict', async () => {
const cbError = new TypeError('custom re-auth crashed');
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
authProvider: {
token: async () => undefined,
onUnauthorized: async () => {
throw cbError;
}
},
fetch: authWallFetch(() => Promise.resolve(new Response(null, { status: 500 })))
});
const out = await runProbe(transport, 'browser');
expect(out.settled).toBe('rejected');
expect(out.error).toBe(cbError);
});
test('B11 regression pin: on the NON-probe path a throwing custom onUnauthorized propagates the raw instance from send()', async () => {
class CallbackError extends Error {
override readonly name = 'CallbackError';
}
const cbError = new CallbackError('boom');
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
authProvider: {
token: async () => undefined,
onUnauthorized: async () => {
throw cbError;
}
},
fetch: authWallFetch(() => Promise.resolve(new Response(null, { status: 500 })))
});
await transport.start();
const rejection = await transport
.send({ jsonrpc: '2.0', id: 1, method: 'tools/list' })
.then(() => {
throw new Error('send unexpectedly resolved');
})
.catch((e: unknown) => e);
// Identity preserved: the stamp is a non-enumerable marker, not a wrap.
expect(rejection).toBe(cbError);
expect(rejection).toBeInstanceOf(CallbackError);
await transport.close();
});
test('control: an error thrown outside every auth seam (the probe POST itself failing) stays a typed negotiation error', async () => {
const netError = new Error('socket hang up');
const fetchMock = (async (): Promise<Response> => {
throw netError;
}) as typeof fetch;
const transport = new StreamableHTTPClientTransport(new URL(SERVER), { fetch: fetchMock });
const out = await runProbe(transport, 'node');
expect(out.settled).toBe('rejected');
expect(out.error).toBeInstanceOf(SdkError);
expect((out.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
expect(((out.error as SdkError).data as { cause?: unknown }).cause).toBe(netError);
});
});

View file

@ -19,7 +19,7 @@
* probe wire shape (string id, `server/discover` first, never a real request).
*/
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
import { LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
import { LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import { Client } from '../../src/client/client';
@ -161,6 +161,56 @@ const CORPUS: CorpusRow[] = [
outcome: { kind: 'result', result: { content: [{ type: 'text', text: `supportedVersions: ["${MODERN}"]` }] } },
expected: 'legacy'
},
// --- Auth statuses are never era evidence (#2561): an auth-protected
// server is not a legacy server, whatever the body says — typed failure,
// never initialize (fallbackAvailable is true in every row here).
{
name: 'auth: HTTP 401 challenge (WWW-Authenticate rides the header; body carries the OAuth error JSON) → typed auth failure, never legacy',
outcome: { kind: 'http-error', status: 401, body: '{"error":"invalid_token","error_description":"Missing bearer token"}' },
expected: 'error'
},
{
name: 'auth: bare HTTP 401 (no body) → typed auth failure, never legacy',
outcome: { kind: 'http-error', status: 401 },
expected: 'error'
},
{
name: 'auth: bare HTTP 403 denial (no body) → typed auth failure, never legacy',
outcome: { kind: 'http-error', status: 403 },
expected: 'error'
},
{
name: 'auth: a 401 whose body parses as a JSON-RPC error is still an auth failure — the auth layer wrote that body, not server/discover',
outcome: { kind: 'http-error', status: 401, body: DEPLOYED_SESSION_REQUIRED_BODY },
expected: 'error'
},
// --- Server failures are never era evidence: the spec keys the HTTP
// legacy signal to a 4xx rejection, so a 5xx (mid-deploy proxy, crashed
// backend) rejects typed instead of demoting a modern server to legacy.
{
name: '5xx: HTTP 503 with an HTML error page (mid-deploy modern server) → typed error, never legacy',
outcome: { kind: 'http-error', status: 503, body: '<html><body>Service Unavailable</body></html>' },
expected: 'error'
},
{
name: '5xx: bare HTTP 500 (no body) → typed error, never legacy',
outcome: { kind: 'http-error', status: 500 },
expected: 'error'
},
{
name: '5xx: HTTP 502 with a JSON (but not JSON-RPC) gateway body → typed error, never legacy',
outcome: { kind: 'http-error', status: 502, body: '{"message":"upstream connect error"}' },
expected: 'error'
},
{
name: '5xx: HTTP 500 whose body parses as a JSON-RPC error (-32603 from a crashed handler) is still a server failure — 5xx ranks above the body parse',
outcome: {
kind: 'http-error',
status: 500,
body: JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32_603, message: 'Internal error' } })
},
expected: 'error'
},
// --- Q12 transport-aware timeout rows (stdio falls back, HTTP stays a typed error).
{
name: 'timeout on stdio → legacy fallback (the stdio backward-compatibility rule)',
@ -209,6 +259,40 @@ describe('T9/T11 merged probe fixture corpus (probe classifier)', () => {
});
}
it('the 401/403 typed failures carry the auth codes (never EraNegotiationFailed — auth walls must not enter era-recovery flows) plus status, reason phrase, and response text', () => {
for (const [status, code, statusText, body] of [
[401, SdkErrorCode.ClientHttpAuthentication, 'Unauthorized', '{"error":"invalid_token"}'],
[403, SdkErrorCode.ClientHttpForbidden, 'Forbidden', 'nope']
] as const) {
const verdict = classifyProbeOutcome({ kind: 'http-error', status, statusText, body }, baseContext);
expect(verdict.kind).toBe('error');
if (verdict.kind === 'error') {
expect(verdict.error).toBeInstanceOf(SdkHttpError);
const error = verdict.error as SdkHttpError;
expect(error.code).toBe(code);
expect(error.status).toBe(status);
expect(error.statusText).toBe(statusText);
expect(error.data.text).toBe(body);
expect(error.message).toContain(String(status));
}
}
});
it('the 5xx typed failure is EraNegotiationFailed (a genuine negotiation failure) carrying the status', () => {
const verdict = classifyProbeOutcome(
{ kind: 'http-error', status: 503, statusText: 'Service Unavailable', body: 'down' },
baseContext
);
expect(verdict.kind).toBe('error');
if (verdict.kind === 'error') {
expect(verdict.error).toBeInstanceOf(SdkHttpError);
const error = verdict.error as SdkHttpError;
expect(error.code).toBe(SdkErrorCode.EraNegotiationFailed);
expect(error.status).toBe(503);
expect(error.message).toContain('503');
}
});
it('a DiscoverResult with a mutual version is the only result shape that yields a modern verdict', () => {
const verdict = classifyProbeOutcome(
{

View file

@ -11,14 +11,18 @@
import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/core-internal';
import {
isJSONRPCRequest,
OAuthError,
PROTOCOL_VERSION_META_KEY,
SdkError,
SdkErrorCode,
SdkHttpError,
UnsupportedProtocolVersionError
} from '@modelcontextprotocol/core-internal';
import { describe, expect, test } from 'vitest';
import { UnauthorizedError } from '../../src/client/auth';
import { InsufficientScopeError } from '../../src/client/authErrors';
import { markAuthSeamEscape } from '../../src/client/authSeam';
import { Client } from '../../src/client/client';
import type { StreamableHTTPClientTransportOptions } from '../../src/client/streamableHttp';
import type { StdioServerParameters } from '../../src/client/stdio';
@ -1103,6 +1107,53 @@ describe('probe send-error classification', () => {
expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false);
});
// Any error stamped at a transport auth seam propagates unchanged —
// identity preserved (same object, instanceof and diagnostics intact),
// never a legacy fallback, never a rewrap. Provenance comes from the
// stamp, not the error type: the rows deliberately span typed SDK
// errors, foreign classes, and a bare TypeError.
const seamEscapes: Array<[string, () => Error]> = [
[
"the transport's post-re-auth 401 (SdkHttpError ClientHttpAuthentication)",
() =>
new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401,
statusText: 'Unauthorized'
})
],
[
'an InsufficientScopeError from a step-up challenge with no provider',
() => new InsufficientScopeError({ requiredScope: 'mcp:tools' })
],
['an OAuthError (invalid_grant on a revoked refresh token)', () => new OAuthError('invalid_grant', 'Refresh token was revoked')],
['a bare TypeError from a DCR sub-fetch inside the auth flow', () => new TypeError('Failed to fetch')],
[
'a custom error class thrown by a user onUnauthorized callback',
() => {
class UserDbError extends Error {
override readonly name = 'UserDbError';
}
return new UserDbError('user db exploded');
}
]
];
for (const [label, make] of seamEscapes) {
test(`stamped seam escape propagates unchanged: ${label}`, async () => {
const reason = markAuthSeamEscape(make());
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' } });

View file

@ -65,16 +65,34 @@ export enum SdkErrorCode {
/**
* Protocol-era negotiation at connect time failed without producing either a
* usable modern (2026-07-28+) era or a definitive legacy fallback signal —
* e.g. the negotiation mode forbids falling back (`pin`), or the probe hit a
* network failure (a typed connect error, never an era verdict).
* e.g. the negotiation mode forbids falling back (`pin`), the probe hit a
* network failure, or the server answered the probe with a 5xx (a typed
* connect error, never an era verdict).
*
* Negotiation-phase only: this code is never used once an era is established.
* Negotiation-phase only: this code is never used once an era is
* established. Auth walls never carry it: a 401/403 rejecting the probe
* uses {@linkcode ClientHttpAuthentication} / {@linkcode ClientHttpForbidden}
* instead, so era-recovery flows keyed on this code (e.g. cached-verdict
* gateways) can never persist a verdict for an unauthorized exchange.
*/
EraNegotiationFailed = 'ERA_NEGOTIATION_FAILED',
// Transport errors
ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED',
/**
* HTTP 401 authentication failure: the transport's re-auth retry still got
* 401 (`Server returned 401 after re-authentication`), or the version
* negotiation probe was rejected 401 with no `authProvider` configured
* (`Version negotiation failed: the server requires authorization (HTTP 401)`).
* Carried on an {@linkcode SdkHttpError} with `status: 401`.
*/
ClientHttpAuthentication = 'CLIENT_HTTP_AUTHENTICATION',
/**
* HTTP 403 denial: the step-up re-authorization retry limit was reached,
* or the version negotiation probe was rejected 403
* (`Version negotiation failed: the server denied access (HTTP 403)`).
* Carried on an {@linkcode SdkHttpError} with `status: 403`.
*/
ClientHttpForbidden = 'CLIENT_HTTP_FORBIDDEN',
ClientHttpUnexpectedContent = 'CLIENT_HTTP_UNEXPECTED_CONTENT',
ClientHttpFailedToOpenStream = 'CLIENT_HTTP_FAILED_TO_OPEN_STREAM',