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>
115 lines
4.5 KiB
TypeScript
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;
|
|
}
|