inspector/core/auth/insecureTokenEndpoint.ts
cliffhall a5a9d4fe15 fix: the classifier does not survive structuredClone, and the test now proves it
Since round 1 the doc claimed the `name` fallback covers "a structured clone or
a JSON hop". Verified, and it is false for half of that:

  structuredClone(new InsecureTokenEndpointError(url))
    -> name: "Error", tokenEndpoint: undefined, constructor: Error

structuredClone normalizes a custom Error subclass back to Error, so neither
arm has anything left to match. A caller routing this error through
structuredClone or postMessage would silently get the generic retryable
handling back — the exact defect this PR removes.

The test could not have caught it: it hand-built a look-alike plain object, so
it asserted what I believed the boundary does rather than what it does. It now
performs a real JSON round trip (asserting the prototype is gone), and a second
case pins that structuredClone is NOT recognized — turning a false claim into a
tested limitation.

The doc comment now scopes the contract to JSON and states the structuredClone
limit with the remedy: serialize the fields explicitly across such a boundary.

Also removes an orphaned JSDoc block the round-4 refactor stranded above the
report helper, and gives showInsecureTokenEndpointNotice its own doc back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 11:16:51 -04:00

115 lines
4.5 KiB
TypeScript

/**
* The SDK refuses to send credentials to a non-TLS token endpoint
* whose host is outside its loopback exemption (`localhost` / `127.0.0.1` /
* `::1`), throwing `InsecureTokenEndpointError` from inside
* `executeTokenRequest`.
*
* That error is **terminal by design**. It does not extend `OAuthError`, and
* `auth()` special-cases it to rethrow rather than fall through to a fresh
* `/authorize` redirect — so nothing the Inspector does can make a retry
* succeed. Recognizing it is what lets the UI say so, instead of offering a
* "Re-authenticate" affordance that can only fail the same way (#2280).
*
* The check is not a fix for `*.localhost` (#1944): the exemption list lives in
* the SDK and takes no options, so widening it has to happen upstream
* (typescript-sdk#2591). This is about how the refusal is *reported* — which
* matters for every endpoint outside that exemption, `host.docker.internal` and
* LAN hostnames included, not only the `.localhost` case.
*/
import { InsecureTokenEndpointError } from "@modelcontextprotocol/client";
/** The fields this module needs off the SDK error, once recognized. */
export interface InsecureTokenEndpointShape {
/** The token endpoint URL the SDK refused to post credentials to. */
tokenEndpoint: string;
}
/**
* Recognize the SDK's `InsecureTokenEndpointError` itself (not a wrapper).
*
* Uses the SDK's own `isInstance` predicate, which is cross-copy safe by
* construction: the SDK stamps each instance with a brand set keyed by
* `Symbol.for("mcp.sdk.errorBrands")` and overrides `Symbol.hasInstance` to
* consult it, so an error thrown by a different bundled copy still matches.
* (The brand constant is `static`, so it is never reachable as `err.mcpBrand`
* on an instance — don't check that property.)
*
* The `name` comparison is the same deliberate serialization fallback
* `isAuthorizationServerMismatchShape` carries in `issuerBinding.ts`. Today the
* web client runs `auth()` in the browser so no boundary is crossed, but a JSON
* hop drops the prototype and the brand set while preserving `name` and
* `tokenEndpoint`, and that is the case this arm exists for.
*
* ⚠️ **It does not cover `structuredClone`, and cannot.** That algorithm
* normalizes a custom `Error` subclass back to `Error` — `name` becomes
* `"Error"` and own properties like `tokenEndpoint` are dropped entirely — so
* nothing survives for either arm to match on. A caller who routes this error
* through `structuredClone` (or `postMessage`, which uses it) will silently get
* the generic retryable handling back. Serialize the fields explicitly across
* such a boundary rather than relying on this classifier.
*/
function isInsecureTokenEndpointShape(
err: unknown,
): err is InsecureTokenEndpointShape {
if (err === null || typeof err !== "object") {
return false;
}
const candidate = err as { tokenEndpoint?: unknown; name?: unknown };
if (typeof candidate.tokenEndpoint !== "string") {
return false;
}
return (
InsecureTokenEndpointError.isInstance(err) ||
candidate.name === "InsecureTokenEndpointError"
);
}
/**
* Find an insecure-token-endpoint refusal anywhere in an error's `cause` /
* `data.cause` chain, and return the shape that carries the endpoint.
*
* Walking the chain is not defensive padding: era negotiation and the transport
* wrappers bury the original rejection, so a top-level-only check would miss the
* connect and refresh paths and let exactly the retryable UI this exists to
* remove render anyway. `findIssuerBindingFailure` in `issuerBinding.ts` walks
* the same two links for the same reason, and this deliberately mirrors it —
* including the `seen` set, which keeps a self-referential `cause` from looping.
*/
export function findInsecureTokenEndpoint(
err: unknown,
): InsecureTokenEndpointShape | undefined {
return findInsecureTokenEndpointDeep(err, new Set());
}
function findInsecureTokenEndpointDeep(
err: unknown,
seen: Set<unknown>,
): InsecureTokenEndpointShape | undefined {
if (err === null || typeof err !== "object" || seen.has(err)) {
return undefined;
}
seen.add(err);
if (isInsecureTokenEndpointShape(err)) {
return err;
}
const nested = findInsecureTokenEndpointDeep(
(err as { cause?: unknown }).cause,
seen,
);
if (nested) {
return nested;
}
const data = (err as { data?: unknown }).data;
if (data !== null && typeof data === "object") {
return findInsecureTokenEndpointDeep(
(data as { cause?: unknown }).cause,
seen,
);
}
return undefined;
}