Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/probe-auth-status-not-era-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@modelcontextprotocol/client': patch
---

The version-negotiation probe no longer misclassifies auth-protected or
failing servers as legacy. Auth status is never era evidence: a 401 or 403
rejection of the `server/discover` probe now surfaces as a typed
authorization failure — an `SdkHttpError` with code `ClientHttpAuthentication`
(401) or `ClientHttpForbidden` (403), carrying the HTTP status, reason
phrase, and response text — instead of triggering the legacy `initialize`
fallback (which put a doomed `initialize` on the wire) or, under `pin` mode,
the false "server did not offer pinned protocol version" diagnostic. The
codes are deliberately not `EraNegotiationFailed`, so era-recovery flows
keyed on that code cannot persist a verdict for an unauthorized exchange. A
5xx rejecting the probe is a server failure and now also rejects typed
(`SdkHttpError(EraNegotiationFailed)`) instead of demoting a mid-deploy
modern server to legacy — the legacy fallback now fires only on the 4xx
shapes the spec licenses.

With an `authProvider`, the transport's auth flow runs first and whatever
escapes it propagates unchanged, identity intact: the HTTP transports stamp
errors at their auth seams (the `token()` read, `onUnauthorized` including
custom callbacks, the 403 step-up flow, and their own auth-failure
constructions), so `UnauthorizedError` for `finishAuth()`, the flow's typed
failures (`OAuthError`, `InsufficientScopeError`, the
401-after-re-authentication diagnostic), and even an untyped `TypeError`
thrown inside the flow all reach the caller as thrown — never rewrapped,
never consumed by the probe's browser CORS heuristic as legacy-era evidence.

Check warning on line 28 in .changeset/probe-auth-status-not-era-evidence.md

View check run for this annotation

Claude / Claude Code Review

Changeset/docs claim untyped auth-flow errors are never consumed by the CORS heuristic, but the stamp mechanism has frozen/sealed/primitive carve-outs

The last paragraph's universal claim — auth-flow errors are "never rewrapped, never consumed by the probe's browser CORS heuristic as legacy-era evidence" (echoed in docs/protocol-versions.md) — is contradicted by `markAuthSeamEscape`'s own documented carve-outs: a frozen/sealed error leaves `defineProperty` throwing and the error unstamped, so a frozen `TypeError` escaping a user auth callback in a browser still takes the CORS row and yields a legacy verdict (resurrecting the false pin-mode dia
Comment on lines +20 to +28

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The last paragraph's universal claim — auth-flow errors are "never rewrapped, never consumed by the probe's browser CORS heuristic as legacy-era evidence" (echoed in docs/protocol-versions.md) — is contradicted by markAuthSeamEscape's own documented carve-outs: a frozen/sealed error leaves defineProperty throwing and the error unstamped, so a frozen TypeError escaping a user auth callback in a browser still takes the CORS row and yields a legacy verdict (resurrecting the false pin-mode diagnostic), and a primitive throw cannot carry the stamp and gets rewrapped as SdkError(EraNegotiationFailed). Fix: qualify this sentence and the docs/protocol-versions.md one (frozen/sealed objects and primitive throws cannot carry the provenance stamp), or back the stamp with a WeakSet fallback when defineProperty throws — WeakSet.add works on frozen objects and preserves identity.

Extended reasoning...

The claim vs. the mechanism's own carve-outs. The changeset's last paragraph (lines 20–28) states that everything escaping a stamped auth seam — "even an untyped TypeError thrown inside the flow" — reaches the caller "as thrown — never rewrapped, never consumed by the probe's browser CORS heuristic as legacy-era evidence." docs/protocol-versions.md makes the same universal claim ("even an untyped crash inside a callback all propagate unchanged"). But packages/client/src/client/authSeam.ts deliberately documents two exceptions in its own JSDoc: "A frozen/sealed object is returned unstamped rather than replaced: identity outranks provenance. Primitive throws cannot carry the stamp." markAuthSeamEscape stamps via Object.defineProperty inside a try/catch whose catch swallows the failure and returns the error unstamped — verified empirically: Object.freeze(new TypeError(...)), Object.seal(...), Object.preventExtensions(...), and a thrown string all come back with isAuthSeamEscape === false.\n\nThe code path that re-opens the exact bug the PR eliminates. For the frozen case, in a browser: (1) a StreamableHTTPClientTransport with an authProvider whose custom onUnauthorized (or token()) throws a frozen TypeError — e.g. a SES/hardened environment or a library that freezes its error singletons; (2) the probe POST gets a readable 401 → _send's 401 branch → the onUnauthorized catch calls markAuthSeamEscape, which silently fails to stamp; (3) normalizeReply (versionNegotiation.ts:385-407): isAuthSeamEscape is false, name is 'TypeError' not 'UnauthorizedError', not SdkHttpErrornetwork-error row; (4) classifyNetworkError (probeClassifier.ts): environment === 'browser' && isOpaqueFetchTypeError(error) — a frozen TypeError is still instanceof TypeError — → { kind: 'legacy' }. The auth-flow crash is consumed as legacy-era evidence.\n\nStep-by-step consequence in pin mode. (1) Client configured versionNegotiation: { mode: { pin: '2026-07-28' } }, browser, provider whose callback throws Object.freeze(new TypeError('re-auth failed')). (2) Probe 401 → callback throws → stamp attempt fails silently. (3) Legacy verdict reaches negotiateEra's pin arm → connect() rejects with SdkError(EraNegotiationFailed, 'Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode)') — byte-for-byte the false diagnostic this PR's motivation quotes as the bug being fixed. In auto mode the doomed legacy initialize goes on the wire and the whole auth flow re-runs (second discovery/DCR round, second consent side effects).\n\nThe primitive case contradicts the other half of the sentence. throw 'oops' from a user callback cannot be stamped, matches no auth row in normalizeReply, and lands in classifyNetworkError's wrap: SdkError(EraNegotiationFailed, 'Version negotiation probe failed: oops', { cause }) — contradicting "never rewrapped" / "reach the caller as thrown" (though at least not a legacy verdict).\n\nWhy existing coverage misses it. The PR's tests only exercise extensible Error instances: probeAuthSeam.test.ts B12 uses a plain TypeError, and the versionNegotiation.test.ts seam rows pre-stamp extensible errors — the carve-out path is untested. This is also not the earlier-reported allowlist gap (the bare extensible TypeError from the DCR sub-fetch, which the seam stamps now fix): it is the residual escape hatch the new mechanism itself defines, intentionally per the code comments, while the changeset and docs deny it exists — the repo's "Documentation & Changesets" recurring catch (prose promising behavior the diff doesn't back).\n\nFix. Either (a) qualify the changeset sentence and the docs/protocol-versions.md sentence with one clause — e.g. "unless the thrown value is frozen/sealed or a primitive, which cannot carry the provenance stamp" — or (b) close the frozen-object hole with a module-level WeakSet fallback in markAuthSeamEscape when defineProperty throws (WeakSet.prototype.add works on frozen objects and preserves identity; the Symbol.for cross-bundle rationale is unaffected since stamper and reader live in the same @modelcontextprotocol/client copy in the common case). Severity is nit because the trigger — a frozen/sealed error or primitive throw escaping a user auth callback, in a browser, under a probing negotiation mode — is exotic, the identity-over-provenance tradeoff is defensible, and nothing breaks in realistic usage.

2 changes: 2 additions & 0 deletions docs/advanced/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@

Replace the persisted blob with the fresh `getDiscoverResult()` and the rest of the fleet recovers on its next read.

The `EraNegotiationFailed` filter above is deliberate and safe against auth walls: a `401`/`403` rejecting the probe carries `ClientHttpAuthentication`/`ClientHttpForbidden` instead, so an unauthorized exchange re-throws out of this recovery path and can never be persisted as an era verdict.

Check warning on line 154 in docs/advanced/gateway.md

View check run for this annotation

Claude / Claude Code Review

gateway.md/changeset absolute never-EraNegotiationFailed claim for 401/403 is false over the SSE transport

The absolute claim added at docs/advanced/gateway.md:154 — that a 401/403 rejecting the probe always carries `ClientHttpAuthentication`/`ClientHttpForbidden` and "can never be persisted as an era verdict" — is false over the legacy `SSEClientTransport`: its `_send` has no typed HTTP rejection for a providerless 401 (and no 403 branch at all), so the plain `Error` it throws is classified as a network failure and wrapped as `SdkError(EraNegotiationFailed)`, which DOES match the gateway recipe's `E

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The absolute claim added at docs/advanced/gateway.md:154 — that a 401/403 rejecting the probe always carries ClientHttpAuthentication/ClientHttpForbidden and "can never be persisted as an era verdict" — is false over the legacy SSEClientTransport: its _send has no typed HTTP rejection for a providerless 401 (and no 403 branch at all), so the plain Error it throws is classified as a network failure and wrapped as SdkError(EraNegotiationFailed), which DOES match the gateway recipe's EraNegotiationFailed-keyed filter. The PR's own docs/protocol-versions.md paragraph already concedes exactly this SSE caveat, contradicting gateway.md and the changeset headline. Fix: qualify the gateway.md sentence (and the changeset) with the same Streamable-HTTP-only caveat protocol-versions.md carries.

Extended reasoning...

The claim. docs/advanced/gateway.md:154 (added by this PR) states: "a 401/403 rejecting the probe carries ClientHttpAuthentication/ClientHttpForbidden instead, so an unauthorized exchange re-throws out of this recovery path and can never be persisted as an era verdict." The changeset .changeset/probe-auth-status-not-era-evidence.md makes the same unqualified headline claim ("a 401 or 403 rejection of the server/discover probe now surfaces as … an SdkHttpError with code ClientHttpAuthentication (401) or ClientHttpForbidden (403)").

Why it's false over the legacy SSE transport. In packages/client/src/client/sse.ts, SSEClientTransport._send's only auth branch is gated on response.status === 401 && this._authProvider, and there is no 403 branch at all. A providerless 401 — or any 403 — on the probe POST falls through to the generic throw new Error(Error POSTing to endpoint (HTTP ${response.status}): ${text}). That plain Error carries no auth-seam stamp (markAuthSeamEscape is only applied to the token() read, onUnauthorized escapes, and the typed UnauthorizedError/SdkHttpError throws), is not an UnauthorizedError, and is not an SdkHttpError.

The classification chain. normalizeReply's send-error row in packages/client/src/client/versionNegotiation.ts: every clause of the auth disjunction misses (no stamp, wrong class, wrong name), and the SdkHttpError branch misses too — so the error lands in { kind: 'network-error' }. classifyNetworkError in packages/client/src/client/probeClassifier.ts then wraps it as SdkError(SdkErrorCode.EraNegotiationFailed, 'Version negotiation probe failed: …'). That is precisely the code the gateway recipe's recovery filter keys on — the exact outcome gateway.md:154 says "can never" happen.

Step-by-step proof. (1) A client uses versionNegotiation: { mode: 'auto' } with an SSEClientTransport and no authProvider against a server whose GET stream opens but whose POST endpoint sits behind an auth wall (returns 403). detectProbeTransportKind returns 'http' for SSE (no stderr/pid accessors), so the probing modes run on it. (2) The probe POST gets the 403; _send has no 403 branch, so it throws the plain Error('Error POSTing to endpoint (HTTP 403): …'). (3) normalizeReplynetwork-errorclassifyNetworkErrorSdkError(EraNegotiationFailed). (4) A gateway following the recipe's error.code === SdkErrorCode.EraNegotiationFailed filter enters the recovery path for an unauthorized exchange — contradicting the sentence's "re-throws out of this recovery path".

The PR is internally inconsistent about this. Its own docs/protocol-versions.md paragraph (same diff) explicitly concedes the gap: "the legacy SSE client transport reports a non-2xx POST as a generic error, so over SSE a probe rejection surfaces as the generic Version negotiation probe failed connect error instead." gateway.md:154 and the changeset omit that qualification while stating the guarantee absolutely.

Why this is a nit, not blocking. No code misbehaves — this is prose-only. SSE is deprecated, every gateway.md example uses StreamableHTTPClientTransport, and even in the SSE scenario the ultimate hazard does not materialize: the recovery branch's re-probe fails against the same auth wall, so connect() rejects before getDiscoverResult() can be read and no wrong verdict is persisted. This matches the repo's Documentation & Changesets recurring catch (prose promising behavior the diff doesn't back).

Fix. Add the same one-clause transport qualification protocol-versions.md already carries to both gateway.md:154 and the changeset headline — e.g. "…carries ClientHttpAuthentication/ClientHttpForbidden instead (on the Streamable HTTP transport; the legacy SSE client transport surfaces an auth-walled POST as the generic EraNegotiationFailed probe failure)…".


## Skip the probe for a known-legacy server

When out-of-band metadata already says the server is pre-2026 — a registry entry, an earlier connection's outcome — an `'auto'`-mode probe is a round trip that fails on every single connect. Supply the negative verdict instead: `PriorDiscovery`'s `{ kind: 'legacy' }` arm skips the probe and goes straight to the `initialize` handshake.
Expand Down
2 changes: 1 addition & 1 deletion docs/clients/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ try {
When the server requires authorization and the provider has no token, the SDK runs discovery against the server, registers (or looks up) your OAuth client, calls the provider's `redirectToAuthorization(url)`, and `connect()` throws `UnauthorizedError`. The end user finishes signing in out of band; your callback endpoint picks the flow back up below.

::: info
With protocol-version negotiation in play, the connect-time 401 can also surface as an `SdkError` carrying the `UnauthorizedError` at `error.data.cause` — see [Protocol versions](../protocol-versions.md).
With protocol-version negotiation in play (`versionNegotiation: { mode: 'auto' }` or a pin), the connect-time `UnauthorizedError` propagates unchanged from `connect()` — the same `instanceof` check works in every mode (older releases wrapped it as an `SdkError` with the error at `error.data.cause`). See [Protocol versions](../protocol-versions.md).
:::

## Implement OAuthClientProvider
Expand Down
10 changes: 9 additions & 1 deletion docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,15 @@ infrastructure problems. Anything the probe does not positively recognize as mod
falls back to the legacy era — provided the supported-versions list still contains a
2025-era revision; with a modern-only list `connect()` rejects with
`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect
error. Probe timeouts are **transport-aware**: on **stdio** a server that does not
error. Auth statuses are another exception: an HTTP `401` or `403` rejecting the probe
is never era evidence — `connect()` rejects with a typed authorization failure (an
`SdkHttpError` with code `ClientHttpAuthentication`/`ClientHttpForbidden` naming the
status, or the transport auth flow's own error propagated unchanged) instead of
falling back — see [Protocol versions](../protocol-versions.md). This holds even for
a front that answers the probe `403` but would pass `initialize`: relying on a legacy
fallback there is a deliberate non-goal — fix the auth wall (or pass credentials),
because auth status never selects an era. A `5xx` on the probe is a server failure,
also never era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)`. Probe timeouts are **transport-aware**: on **stdio** a server that does not
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
(some legacy servers never respond to unknown pre-`initialize`
requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` —
Expand Down
10 changes: 5 additions & 5 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1150,11 +1150,11 @@ try {
}
```

One qualification: this direct `instanceof` check applies under the default `'legacy'`
version negotiation. Under the probing modes (`versionNegotiation: { mode: 'auto' }`,
with or without a pin) the connect-time 401 currently surfaces wrapped as
`SdkError(SdkErrorCode.EraNegotiationFailed)` with the `UnauthorizedError` at
`error.data.cause` — unwrap before the check, as shown in the
This direct `instanceof` check works in every version-negotiation mode: under the
probing modes (`versionNegotiation: { mode: 'auto' }`, with or without a pin) the
connect-time `UnauthorizedError` also propagates unchanged from `connect()`. Older
releases wrapped it as `SdkError(SdkErrorCode.EraNegotiationFailed)` with the error at
`error.data.cause` — that unwrap is no longer needed. See the
[client OAuth guide](../clients/oauth.md).
Comment thread
claude[bot] marked this conversation as resolved.

#### `auth()` options are now `AuthOptions`
Expand Down
2 changes: 2 additions & 0 deletions docs/protocol-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ const cli = new Client(

A probe timeout is transport-aware. On stdio a silent server is a legacy server, so `connect()` falls back to `initialize`; on HTTP silence is an outage, so `connect()` rejects with `SdkError(RequestTimeout)` instead of misreporting a dead server as legacy. One browser exception: an opaque CORS `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have allow-lists that predate the 2026 headers.

Auth statuses are not era evidence either. An HTTP `401` or `403` rejecting the probe surfaces as a typed authorization failure, never the legacy fallback — and never as `EraNegotiationFailed`, so era-recovery flows keyed on that code (the [gateway guide](./advanced/gateway.md) recipe) cannot consume an auth wall. A plain `401` or `403` with no `authProvider` rejects `connect()` with an `SdkHttpError` carrying the status: code `ClientHttpAuthentication` for `401`, `ClientHttpForbidden` for `403`. One 403 shape is different: a `WWW-Authenticate` challenge with `error="insufficient_scope"` enters the Streamable HTTP transport's step-up flow regardless of provider, and with none it rejects with the flow's typed `InsufficientScopeError`. With a provider, the auth flow runs first and whatever escapes it reaches you as thrown, identity intact — the transport stamps errors at its auth seams (the `token()` read, `onUnauthorized` including custom callbacks, step-up), so `UnauthorizedError` for `finishAuth()`, the flow's typed failures (`OAuthError`, `InsufficientScopeError`, the 401-after-re-authentication diagnostic), and even an untyped crash inside a callback all propagate unchanged. A `5xx` rejecting the probe is a server failure, not era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)` naming the status. These status-keyed rows read the transport's typed HTTP rejections — the SDK's Streamable HTTP transport surfaces them with the status attached; the legacy SSE client transport reports a non-2xx POST as a generic error, so over SSE a probe rejection surfaces as the generic `Version negotiation probe failed` connect error instead. Auth settles first, era second: a `401` never decides the era — the auth wall answers before the MCP layer ever sees `server/discover` — and the post-auth re-probe supplies the real era evidence.

On the SDK's own stdio transport (exactly `StdioClientTransport` — subclasses, like custom stdio-shaped transports, probe in place) the probe runs on a short-lived **sibling process** spawned from the same parameters — some stdio servers exit on any pre-`initialize` request (servers built on the official Rust SDK, rmcp, behave this way), so the probe must not spend the caller's one child process. The sibling is invisible infrastructure: its stderr is discarded and it is reaped once the era is known; the caller's transport spawns exactly once, afterwards, and its wire never carries `server/discover`. A child that exits on the probe is simply a legacy server (its exit must close the child's stdio pipes to register — an exit hidden behind a helper process holding them open falls to the probe-timeout path). Closing the caller's transport during the probe aborts `connect()` with a typed `SdkError(EraNegotiationFailed)` and the session child is never spawned. On HTTP — and on custom stdio-shaped transports, which probe in place — a mid-probe connection close rejects with the same typed error as any probe transport failure.

The client's `supportedProtocolVersions` option shapes the probe: its 2026+ entries are the versions the probe offers, and the legacy fallback stays available only while the list keeps a pre-2026 entry. A list with no pre-2026 entry removes the fallback — against a 2025-only server, `connect()` rejects with `SdkError(EraNegotiationFailed)`.
Expand Down
9 changes: 9 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan
- `the connection closed during the server/discover probe (this transport probed in place — the disposable sibling probe requires the SDK's base StdioClientTransport)` — a subclass of `StdioClientTransport`, or a custom stdio-shaped transport, probed in place and met a server that exits on any pre-`initialize` request: use the base `StdioClientTransport` (which probes on a disposable sibling), or `mode: 'legacy'`.
- `the transport was closed during the server/discover probe` — the caller closed the transport while the probe was in flight; the connect aborted deliberately and the session child was never spawned.
- `Version negotiation probe failed: ...` — the probe hit a transport failure (network outage, HTTP connection drop): fix connectivity and retry.
- `the server answered the probe with HTTP 5xx` — the server or a proxy in front of it failed (mid-deploy, crashed backend); not era evidence, so no legacy fallback is attempted: retry once the deployment is healthy.

A `401`/`403` probe rejection is **not** this code — see the next section.

The pinned shape — `transport` here reaches a server still on the 2025 revisions ([Test a server](./testing.md) shows the in-memory wiring these outputs come from):

Expand Down Expand Up @@ -112,6 +115,12 @@ legacy

Keep `{ pin }` where a legacy connection is unacceptable and a hard failure is the behavior you want. [Protocol versions](./protocol-versions.md) defines the eras and what each negotiation mode offers.

## `Version negotiation failed: the server requires authorization (HTTP 401)`

`SdkHttpError` with code `CLIENT_HTTP_AUTHENTICATION` (`error.status === 401`): the negotiation probe hit an auth wall and no `authProvider` is configured. Auth status is never protocol-era evidence, so `connect()` fails typed instead of guessing an era. Pass an `authProvider` — `{ token: async () => myApiKey }` for bearer tokens, or an `OAuthClientProvider` for OAuth flows (the connect-time `UnauthorizedError` → `finishAuth()` → reconnect cycle then works in every negotiation mode).

The `403` sibling — `Version negotiation failed: the server denied access (HTTP 403)`, code `CLIENT_HTTP_FORBIDDEN` — means the server refused the request outright (IP allowlist, org policy, revoked key). Fix access; this is not a protocol mismatch. (A `403` whose `WWW-Authenticate` challenge carries `error="insufficient_scope"` instead rejects with `InsufficientScopeError` — request the challenged scope.)

## `SdkError: METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION`

You sent a spec method the negotiated protocol era does not define. The SDK raises this locally — nothing reached the transport — and the message names the method, the negotiated revision, and the era-appropriate replacement.
Expand Down
16 changes: 3 additions & 13 deletions examples/cli-client/host/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,13 @@ import {
Client,
LOG_LEVEL_META_KEY,
ProtocolError,
SdkError,
StreamableHTTPClientTransport,
SUPPORTED_PROTOCOL_VERSIONS,
UnauthorizedError
} from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

import type { ChatMessage, ContentPart, GenerateResult, LLMProvider, ToolCall, ToolDefinition } from '../providers/provider';
import { isRecord } from '../providers/provider';
import { completeAuthorizationWithBrowser, createOAuthProvider, findCallbackPort, isSafeBrowserUrl } from './auth';
import type { CliClientConfig, ServerConfig } from './config';
import { isHttpServer } from './config';
Expand Down Expand Up @@ -100,16 +98,6 @@ export function resolveVersionOptions(legacy: boolean, protocolVersion?: string)
return { versionNegotiation: { mode: { pin: protocolVersion } } };
}

function unwrapUnauthorized(error: unknown): UnauthorizedError | undefined {
if (error instanceof UnauthorizedError) return error;
// Under versionNegotiation 'auto', a connect-time 401 surfaces as
// SdkError(EraNegotiationFailed) with the UnauthorizedError in error.data.cause.
if (error instanceof SdkError && isRecord(error.data) && error.data.cause instanceof UnauthorizedError) {
return error.data.cause;
}
return undefined;
}

function samplingContentToParts(content: CreateMessageRequest['params']['messages'][number]['content']): ContentPart[] {
const blocks = Array.isArray(content) ? content : [content];
const parts: ContentPart[] = [];
Expand Down Expand Up @@ -488,7 +476,9 @@ export class McpHost {
try {
await client.connect(httpTransport);
} catch (error) {
if (!unwrapUnauthorized(error)) throw error;
// A connect-time 401 propagates UnauthorizedError unchanged
// in every negotiation mode, including the probing ones.
if (!(error instanceof UnauthorizedError)) throw error;
const finishTransport = httpTransport;
const authorized = await completeAuthorizationWithBrowser({
serverName: name,
Expand Down
40 changes: 40 additions & 0 deletions packages/client/src/client/authSeam.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
Loading
Loading