fix: report a terminal OAuth token-endpoint refusal instead of a dead retry

Closes #2280

InsecureTokenEndpointError appeared nowhere in our source, so it fell through
to the generic auth-failure path and rendered a "Re-authentication required"
banner with a Re-authenticate button. That button can never work: the SDK's
assertSecureTokenEndpoint runs inside executeTokenRequest, the error does not
extend OAuthError, and auth() special-cases it to rethrow rather than start a
fresh /authorize redirect. Clicking re-ran the same flow to the same refusal,
under the raw SDK message, which names the three exempt host literals and
nothing actionable. A terminal configuration error was being presented as a
retryable auth error.

Not specific to *.localhost: it fires for any endpoint outside the SDK's
exemption — host.docker.internal (#1911), a LAN hostname, a reverse-proxy
name, a mistyped scheme.

It is now recognized (core/auth/insecureTokenEndpoint.ts, mirroring
issuerBinding.ts's brand-plus-name classifier and walking cause / data.cause,
since era negotiation and the transport wrappers bury the rejection) and
surfaced as the configuration error it is, naming the endpoint and both ways
out, with no action affordance.

Six paths reach the refusal and all six classify it — the connect handshake,
authenticate() during connect, the satisfied-challenge connect retry, the
post-redirect callback, the command/background path, the deferred tab-visible
resume (which re-armed on every focus, an unbounded loop on a terminal error),
and the banner action. They share one wrapper so a seventh cannot omit the
banner clear, and every clear is scoped to its own serverId: these paths are
asynchronous, so a late continuation for one server must not erase a banner
another raised in the meantime.

Adds oauth-insecure-token-endpoint-http.json to reproduce it, whose issuer is
http://localhost.:8091 — the root-anchored spelling every resolver sends to
loopback but which is none of the SDK's exempt literals. transport.strictPort
keeps that fixture honest, since its port is hard-coded inside the issuer.

Making such an endpoint work has to land in the SDK (typescript-sdk#2591);
this changes the reporting, not the outcome.

Split out of #2282, where it was tangled with the *.localhost origin work for
#1944 — a separate question the reporter is still clarifying.

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>
This commit is contained in:
cliffhall 2026-09-07 09:09:54 -04:00
commit b4b82493b1
19 changed files with 1400 additions and 9 deletions

View file

@ -83,6 +83,7 @@ usually looks like a missing capability rather than an error.
| A tool result's `structuredContent` section | `structured-output-http.json` (legacy) |
| RFC 6570 resource-template expansion | `rfc6570-templates-http.json` |
| OAuth token revocation on clear | `oauth-revocation-http.json` (legacy) |
| A token endpoint the SDK refuses (SEP-2207) | `oauth-insecure-token-endpoint-http.json` (legacy) |
| Cancelling a call mid-flight | `cancellation-modern-http.json` (modern) |
## Adding a config or preset

View file

@ -11,6 +11,7 @@ import type { ClientConfig } from "@inspector/core/client/types.js";
import type { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import { EmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
import { InsecureTokenEndpointError } from "@modelcontextprotocol/client";
import { renderWithMantine, act, waitFor } from "../test/renderWithMantine";
import { EMPTY_SETTINGS } from "../utils/serverSettingsDefaults";
import { DEEP_LINK_SERVER_ID } from "../utils/deepLink";
@ -258,6 +259,27 @@ const lastClient = (h: Harness): InspectorClient => {
return client;
};
/**
* The last updater handed to `setReAuthBanner`, applied to a banner.
*
* Every terminal SEP-2207 arm clears the banner with a **functional** update
* guarded on `serverId`, because these paths are asynchronous and a late
* continuation for one server must not erase a banner another raised in the
* meantime. The harness's setter is a spy, so the updater is never invoked for
* us — asserting it directly is what actually exercises the guard rather than
* merely reaching the line.
*/
const applyBannerUpdate = (
spy: ReturnType<typeof vi.fn>,
banner: { serverId: string; message: string } | null,
) => {
const updater = spy.mock.calls.at(-1)?.[0] as unknown;
if (typeof updater !== "function") {
throw new Error("expected a functional setReAuthBanner update");
}
return (updater as (prev: unknown) => unknown)(banner);
};
const toastTitles = (): string[] =>
notificationsMock.show.mock.calls.map((c) => String(c[0]?.title));
@ -601,6 +623,128 @@ describe("useConnectionLifecycle", () => {
expect(h.spies.setFailedServerId).not.toHaveBeenCalledWith("a");
});
it("reports an insecure token endpoint as terminal, without flagging the card", async () => {
// SEP-2207 (#2280). Asserted on the hook, not just the notice helper,
// because what makes this arm correct is its *position*: above
// `setFailedServerId` and above the generic toast. A helper-only test
// cannot see either of those go wrong.
connectSpy.mockRejectedValueOnce(
new InsecureTokenEndpointError(
"http://tenant.app.localhost:3300/token",
),
);
const h = harness({ servers: [entry("a")] });
await act(async () => {
await h.api().onToggleConnection("a");
});
expect(toastTitles()).toContain("Token endpoint is not secure");
// The generic arm must not also fire — two notifications for one failure
// is how the raw SDK text would creep back in beside the good copy.
expect(toastTitles()).not.toContain('Failed to connect to "Server a"');
expect(h.spies.setFailedServerId).not.toHaveBeenCalledWith("a");
// The clear is scoped: it drops this server's banner and spares another's.
expect(
applyBannerUpdate(h.spies.setReAuthBanner, {
serverId: "a",
message: "x",
}),
).toBeNull();
expect(
applyBannerUpdate(h.spies.setReAuthBanner, {
serverId: "other",
message: "x",
}),
).toMatchObject({ serverId: "other" });
// The real `connect()` sets status `"error"` and dispatches
// `statusChange` before rethrowing, which paints the card red and pins
// the monitoring sidebar open — presenting this as the failed connect
// attempt the notice says it is not. `connect` is mocked here, so the
// teardown is what this asserts; without it the client is left in that
// state.
expect(disconnectSpy).toHaveBeenCalled();
});
it("finds an insecure token endpoint wrapped under `cause` on the connect path", async () => {
// Era negotiation and the transport wrappers bury the rejection, so the
// shallow check this replaced would have missed exactly this shape.
connectSpy.mockRejectedValueOnce(
new Error("connect failed", {
cause: new InsecureTokenEndpointError("http://localhost.:8091/token"),
}),
);
const h = harness({ servers: [entry("a")] });
await act(async () => {
await h.api().onToggleConnection("a");
});
expect(toastTitles()).toContain("Token endpoint is not secure");
expect(h.spies.setFailedServerId).not.toHaveBeenCalledWith("a");
expect(disconnectSpy).toHaveBeenCalled();
});
it("reports an insecure token endpoint raised by the 401 authorization attempt", async () => {
// The second of the two arms: `authenticate()` rejects rather than the
// opening handshake, which is the path a refresh takes.
connectSpy.mockRejectedValueOnce(unauthorized());
authenticateSpy.mockRejectedValueOnce(
new InsecureTokenEndpointError("http://localhost.:8091/token"),
);
const h = harness({ servers: [entry("a")] });
await act(async () => {
await h.api().onToggleConnection("a");
});
expect(toastTitles()).toContain("Token endpoint is not secure");
expect(toastTitles()).not.toContain(
'OAuth authorization failed for "Server a"',
);
expect(h.spies.setFailedServerId).not.toHaveBeenCalledWith("a");
// The generic arm also records this as the connect error banner text;
// the terminal arm returns before that, so it must stay unset.
expect(h.api().connectErrorMessage).toBeUndefined();
});
it("reports a terminal refusal raised by the retried connect, without flagging the card", async () => {
// The satisfied-challenge retry still ends in a token exchange, so it can
// raise this on its own. Reporting it as a failed connect is doubly wrong
// here: the Inspector has just told the user the authorization worked
// (#2280).
connectSpy
.mockRejectedValueOnce(
new AuthRecoveryRequiredError(
new URL("https://as.example/authorize"),
{ reason: "unauthorized" },
),
)
.mockRejectedValueOnce(
new InsecureTokenEndpointError("http://localhost.:8091/token"),
);
checkSpy.mockResolvedValueOnce(true);
const h = harness({ servers: [entry("a")] });
await act(async () => {
await h.api().onToggleConnection("a");
});
expect(connectSpy).toHaveBeenCalledTimes(2);
expect(toastTitles()).toContain("Token endpoint is not secure");
expect(toastTitles()).not.toContain('Failed to connect to "Server a"');
expect(h.spies.setFailedServerId).not.toHaveBeenCalledWith("a");
expect(
applyBannerUpdate(h.spies.setReAuthBanner, {
serverId: "other",
message: "x",
}),
).toMatchObject({ serverId: "other" });
expect(h.api().connectErrorMessage).toBeUndefined();
// The teardown the generic arm does is still required on this one.
expect(disconnectSpy).toHaveBeenCalled();
});
it("retries the connect when the auth challenge is already satisfied", async () => {
connectSpy.mockRejectedValueOnce(
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
@ -1211,6 +1355,39 @@ describe("useConnectionLifecycle", () => {
);
});
it("reports a terminal token-endpoint refusal from the banner action", async () => {
// The path a user reaches by *acting*: an ordinary re-auth banner, they
// click Re-authenticate, and the exchange is refused. The worst place to
// fall back to the raw SDK text, since they have just been told that
// retrying is the fix (#2280).
const h = harness({ servers: [entry("a")] });
await act(async () => {
await h.api().onToggleConnection("a");
});
const client = lastClient(h);
authenticateSpy.mockRejectedValue(
new InsecureTokenEndpointError("http://localhost.:8091/token"),
);
h.rerender({
servers: [entry("a")],
activeServerId: "a",
connectionStatus: "connected",
client,
reAuthBanner: { serverId: "a", message: "lapsed" },
});
await act(async () => {
h.api().onReauthenticateFromBanner();
});
await waitFor(() =>
expect(toastTitles()).toContain("Token endpoint is not secure"),
);
expect(toastTitles()).not.toContain(
'OAuth authorization failed for "Server a"',
);
});
it("falls back to an unnamed failure toast for an unknown server", async () => {
const h = harness({ servers: [entry("a")] });
await act(async () => {

View file

@ -30,8 +30,11 @@ import {
getActiveEnterpriseManagedAuthIdp,
} from "@inspector/core/client/types.js";
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
import { showInsecureTokenEndpointNotice } from "../lib/insecureTokenEndpointNotice";
import { findInsecureTokenEndpoint } from "@inspector/core/auth/insecureTokenEndpoint.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
import type { Dispatch, SetStateAction } from "react";
import type { SessionRef } from "./useSessionRef";
import type { FetchLogOptions } from "./useInspectorStores";
import type { LastPersistedSettings } from "./useLastPersistedSettings";
@ -172,7 +175,7 @@ export interface UseConnectionLifecycleOptions {
prepareOAuthRedirect: (args: PrepareOAuthRedirectArgs) => void;
finalizeExplicitDisconnect: () => void;
reAuthBanner: ReAuthBannerState | null;
setReAuthBanner: (next: ReAuthBannerState | null) => void;
setReAuthBanner: Dispatch<SetStateAction<ReAuthBannerState | null>>;
/** See `SessionResetSurface`. */
sessionReset: SessionResetSurface;
@ -637,6 +640,30 @@ export function useConnectionLifecycle({
});
return;
}
// SEP-2207 (#2280): a token endpoint the SDK will not post credentials
// to. Terminal, so it gets a notice of its own rather than the generic
// "Failed to connect" toast, whose detail line would be the raw SDK
// text.
//
// The teardown is load-bearing, not tidiness. `connect()` sets its
// status to `"error"` and dispatches `statusChange` *before* rethrowing
// (it is not a connect-auth-recovery error), and `InspectorView` pins
// the monitoring sidebar open on that transition and paints the card
// red. Returning without it would present this as the failed connect
// attempt the notice explicitly says it is not. The `authenticate()`
// arm below already disconnects for the same reason.
if (findInsecureTokenEndpoint(err)) {
await client.disconnect().catch(() => {});
showInsecureTokenEndpointNotice(err, target.name);
// Clear a banner left by an earlier failure: its Re-authenticate
// button is just as dead as the one this arm declines to offer, and
// the user cannot tell which failure it belongs to. Scoped to this
// server, so an async continuation cannot erase another's.
setReAuthBanner((prev) =>
prev && prev.serverId === id ? null : prev,
);
return;
}
// A 401 from an OAuth-protected server means we have no (valid) token
// yet. Kick off the authorization-code flow: `authenticate()` runs
@ -671,6 +698,22 @@ export function useConnectionLifecycle({
// held. The fetch log survives a disconnect, so the Network
// diagnostics this issue is about are unaffected.
await client.disconnect().catch(() => {});
// SEP-2207 (#2280). The retried `connect()` above can raise the
// terminal refusal on its own — a satisfied challenge still ends in
// a token exchange — and reporting that as a failed connect attempt
// is doubly wrong here: the card goes red and the message is the
// raw SDK text, on the one path where the Inspector had just told
// the user the authorization *worked*. Placed after the teardown
// above, which this arm needs for the same reason the generic one
// does, and before the flag it must not set.
if (showInsecureTokenEndpointNotice(recoveryErr, target.name)) {
// Only this server's banner — an async continuation must not
// erase one raised for a server the user has since switched to.
setReAuthBanner((prev) =>
prev && prev.serverId === id ? null : prev,
);
return;
}
setFailedServerId(id);
const message =
recoveryErr instanceof Error
@ -722,6 +765,14 @@ export function useConnectionLifecycle({
});
return;
}
// See the SEP-2207 note on the handshake arm above (#2280). The
// disconnect already happened at the top of this catch.
if (showInsecureTokenEndpointNotice(authErr, target.name)) {
setReAuthBanner((prev) =>
prev && prev.serverId === id ? null : prev,
);
return;
}
// The connect attempt failed, same as any other handshake error —
// flag the card (#1621) and, with it, open the monitoring sidebar
// onto the OAuth requests that explain the failure (#2108). This
@ -766,6 +817,7 @@ export function useConnectionLifecycle({
setFailedServerId,
prepareOAuthRedirect,
finalizeExplicitDisconnect,
setReAuthBanner,
],
);
@ -962,6 +1014,16 @@ export function useConnectionLifecycle({
authorizationUrl: authUrl,
});
} catch (err) {
// SEP-2207 (#2280), and this is the path a user reaches by *acting*:
// a connected session raises an ordinary re-auth banner, they click
// Re-authenticate, and the token exchange is refused. Without this
// the generic toast below reports it with the raw SDK text — the
// worst place to lose the guidance, since they have just been told
// retrying is the fix. No banner clear is needed: this callback
// already cleared it before starting.
if (showInsecureTokenEndpointNotice(err, server?.name)) {
return;
}
const message = err instanceof Error ? err.message : String(err);
notifications.show({
title: server

View file

@ -9,6 +9,7 @@ import type {
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import { EmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
import { InsecureTokenEndpointError } from "@modelcontextprotocol/client";
import { useEffect, useLayoutEffect, useRef } from "react";
import { renderWithMantine, act, waitFor } from "../test/renderWithMantine";
import {
@ -979,6 +980,137 @@ describe("useOAuthRecovery", () => {
});
});
it("claims an insecure token endpoint on the command path instead of rethrowing", async () => {
// SEP-2207 (#2280). A mid-session silent refresh rejects here rather than
// as an AuthRecoveryRequiredError, so before this it was rethrown into
// the generic reporting below.
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
await expect(
h
.api()
.runWithCommandAuthRecovery(
() =>
Promise.reject(
new InsecureTokenEndpointError(
"http://localhost.:8091/token",
),
),
"tool",
),
).resolves.toBeUndefined();
});
expect(toastTitles()).toContain("Token endpoint is not secure");
});
it("does not clear a banner belonging to a different server", async () => {
// The paths are asynchronous: server A can reject long after the user
// switched away and server B raised its own banner. An unconditional
// clear would erase B's, which is still valid and still actionable.
const client = fakeClient();
const h = harness({
servers: [entry("a"), entry("b")],
activeServerId: "b",
client,
});
await act(async () => {
client.emit("oauthError", { error: new Error("session expired") });
});
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("b"));
// The user switches to "a"; a stale continuation for it now rejects.
h.rerender({
servers: [entry("a"), entry("b")],
activeServerId: "a",
client,
});
await act(async () => {
await h
.api()
.runWithCommandAuthRecovery(
() =>
Promise.reject(
new InsecureTokenEndpointError("http://localhost.:8091/token"),
),
"tool",
);
});
expect(toastTitles()).toContain("Token endpoint is not secure");
// B's banner survives — it is still valid and still actionable.
expect(h.api().reAuthBanner?.serverId).toBe("b");
});
it("clears a stale banner when a command-path failure is terminal", async () => {
// Every terminal arm goes through one wrapper for this reason: a banner
// left by an earlier failure carries a Re-authenticate button just as
// dead as the one this arm declines to offer, and the user cannot tell
// which failure it belongs to.
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
client.emit("oauthError", { error: new Error("session expired") });
});
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
await act(async () => {
await h
.api()
.runWithCommandAuthRecovery(
() =>
Promise.reject(
new InsecureTokenEndpointError("http://localhost.:8091/token"),
),
"tool",
);
});
expect(toastTitles()).toContain("Token endpoint is not secure");
expect(h.api().reAuthBanner).toBeNull();
});
it("shows the terminal notice instead of the generic title in the background form", async () => {
// The `errorTitle` call sites would otherwise render the raw SDK text
// under a generic heading.
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
h.api().runCommandInBackground(
() =>
Promise.reject(
new InsecureTokenEndpointError("http://localhost.:8091/token"),
),
"ambient",
"Refresh failed",
);
await Promise.resolve();
});
await waitFor(() =>
expect(toastTitles()).toContain("Token endpoint is not secure"),
);
expect(toastTitles()).not.toContain("Refresh failed");
});
it("still reports it at a call site whose panel owns reporting", async () => {
// The worse half of the old behavior: with no `errorTitle` the rejection
// was swallowed outright and the command just appeared to do nothing.
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
h.api().runCommandInBackground(
() =>
Promise.reject(
new InsecureTokenEndpointError("http://localhost.:8091/token"),
),
"ambient",
);
await Promise.resolve();
});
await waitFor(() =>
expect(toastTitles()).toContain("Token endpoint is not secure"),
);
});
it("toasts a background failure only when given a title", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
@ -1181,6 +1313,28 @@ describe("useOAuthRecovery", () => {
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
});
it("clears a banner already on screen when a later oauthError is terminal", async () => {
// Otherwise the stale Re-authenticate button sits beside the terminal
// notice — the affordance this change removes, sourced from an earlier
// failure rather than this one.
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
client.emit("oauthError", { error: new Error("token endpoint 500") });
});
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
await act(async () => {
client.emit("oauthError", {
error: new InsecureTokenEndpointError("http://localhost.:8091/token"),
});
});
await waitFor(() =>
expect(toastTitles()).toContain("Token endpoint is not secure"),
);
expect(h.api().reAuthBanner).toBeNull();
});
it("ignores an oauthError with no active server", async () => {
const client = fakeClient();
const h = harness({ servers: [], activeServerId: undefined, client });
@ -1344,6 +1498,41 @@ describe("useOAuthRecovery", () => {
);
});
it("does not re-arm a deferred recovery whose failure is terminal", async () => {
// The restore's premise is that the recovery is still owed and a later
// trigger should retry it. For a refusal that can only fail the same way,
// re-arming means every future tab focus replays it under a toast
// promising a retry that cannot succeed — an unbounded loop on a terminal
// error (#2280).
const client = fakeClient({
handleAuthChallenge: vi
.fn()
.mockRejectedValue(
new InsecureTokenEndpointError("http://localhost.:8091/token"),
),
});
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await defer(client, h);
await act(async () => {
becomeVisible();
});
await waitFor(() =>
expect(toastTitles()).toContain("Token endpoint is not secure"),
);
// Neither the retry promise nor the slot that would make good on it.
// `pendingReauth` is not on the hook's public surface, so it is read back
// through the commit probe, as the step-up tests above do.
expect(toastTitles()).not.toContain("Could not continue authorization");
expect(h.commits[h.commits.length - 1]?.reauthServerId).toBeUndefined();
// The load-bearing assertion: coming back to the tab does not replay it.
await act(async () => {
becomeVisible();
});
expect(client.handleAuthChallenge).toHaveBeenCalledTimes(1);
});
it("does not put a stale challenge back over a newer deferral", async () => {
// The tab can go hidden mid-resume and a newer challenge defer itself
// into the slot; that one describes the session as it is now, so the
@ -1686,6 +1875,45 @@ describe("useOAuthRecovery", () => {
expect(h.spies.setFailedServerId).not.toHaveBeenCalled();
});
it("reports an insecure token endpoint terminally, with no banner and no red card", async () => {
// SEP-2207 (#2280). The three assertions are the whole point of the arm's
// position: the banner would carry a Re-authenticate button that cannot
// work, and flagging the card would present a configuration error as a
// failed connect attempt.
snapshot();
const client = fakeClient({
resumeAfterOAuth: vi
.fn()
.mockRejectedValue(
new InsecureTokenEndpointError("http://localhost.:8091/token"),
),
});
const h = callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
await waitFor(() =>
expect(toastTitles()).toContain("Token endpoint is not secure"),
);
expect(h.api().reAuthBanner).toBeNull();
expect(h.spies.setFailedServerId).not.toHaveBeenCalled();
});
it("finds an insecure token endpoint wrapped under `cause` on the callback leg", async () => {
snapshot();
const client = fakeClient({
resumeAfterOAuth: vi.fn().mockRejectedValue(
new Error("resume failed", {
cause: new InsecureTokenEndpointError(
"http://localhost.:8091/token",
),
}),
),
});
const h = callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
await waitFor(() =>
expect(toastTitles()).toContain("Token endpoint is not secure"),
);
expect(h.api().reAuthBanner).toBeNull();
});
it("offers one-click recovery when the authorization state was lost", async () => {
snapshot();
const client = fakeClient({

View file

@ -1,3 +1,4 @@
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
import { notifications } from "@mantine/notifications";
@ -30,6 +31,7 @@ import {
emaStepUpSuccessMessage,
} from "@inspector/core/auth/oauthUx.js";
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
import { showInsecureTokenEndpointNotice } from "../lib/insecureTokenEndpointNotice";
import type { OAuthDetails } from "../components/groups/ConnectionInfoContent/ConnectionInfoContent";
import { oauthDetailsFromConnectionState } from "../components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
import { getWebRemoteOAuthStorage } from "../lib/remoteOAuthStorage";
@ -210,7 +212,13 @@ export interface OAuthRecovery {
onBeforeOAuthRedirect: (authorizationUrl: URL) => void;
prepareOAuthRedirect: (args: PrepareOAuthRedirectArgs) => void;
reAuthBanner: ReAuthBannerState | null;
setReAuthBanner: (next: ReAuthBannerState | null) => void;
/**
* The raw state setter, functional form included. Consumers need the updater
* to clear a banner **only when it belongs to the server they are reporting
* on** — these paths are asynchronous, so a late continuation for server A
* must not erase a banner server B raised in the meantime.
*/
setReAuthBanner: Dispatch<SetStateAction<ReAuthBannerState | null>>;
/**
* Drops the banner and both pending-OAuth slots. Called from the session
* reset, which runs on every disconnect: an unanswered step-up prompt or a
@ -383,6 +391,41 @@ export function useOAuthRecovery({
[sessionRef],
);
/**
* Report a terminal SEP-2207 refusal (#2280) and clear any re-auth banner.
*
* Every arm goes through this rather than calling the notice helper directly.
* The banner clear is not incidental: a banner left from an *earlier* failure
* carries a Re-authenticate button that is just as dead as the one this
* change removes, and the user cannot tell which failure it belongs to. Round
* 4 fixed that for one arm by hand; wrapping it is what stops the next arm
* from omitting it.
*
* Returns whether the error was claimed, so callers keep their fall-through.
*/
const reportTerminalInsecureTokenEndpoint = useCallback(
(
err: unknown,
serverId: string | undefined,
serverName?: string,
): boolean => {
if (!showInsecureTokenEndpointNotice(err, serverName)) {
return false;
}
// Clear only *this* server's banner. The command and deferred-resume
// paths are asynchronous, so server A can reject long after the user
// switched away and server B raised a banner of its own; an unconditional
// clear would then erase B's, which is still valid and still actionable.
// Functional so it sees the queued state rather than the render-time
// value, matching how `setPendingReauth` guards its own late restore.
setReAuthBanner((prev) =>
prev && prev.serverId === serverId ? null : prev,
);
return true;
},
[setReAuthBanner],
);
const showReAuthBanner = useCallback(
(
serverId: string,
@ -390,6 +433,14 @@ export function useOAuthRecovery({
options?: { reason?: AuthChallengeReason },
) => {
const server = sessionRef.current.servers.find((s) => s.id === serverId);
// SEP-2207 (#2280). The SDK rethrows `InsecureTokenEndpointError` instead
// of retrying, so the banner's "Re-authenticate" could only fail the same
// way. Claimed here, at the single funnel every re-auth banner goes
// through, rather than at each of its call sites — a new caller then gets
// the right behavior by default instead of by remembering.
if (reportTerminalInsecureTokenEndpoint(detail, serverId, server?.name)) {
return;
}
const message = reAuthBannerMessage({
serverName: server?.name,
detail:
@ -410,7 +461,7 @@ export function useOAuthRecovery({
message,
});
},
[sessionRef],
[sessionRef, reportTerminalInsecureTokenEndpoint],
);
/** Clears pending OAuth resume state — explicit user disconnect only. */
@ -811,10 +862,35 @@ export function useOAuthRecovery({
}
return undefined;
}
// SEP-2207 (#2280), on the command path. A mid-session silent refresh
// against an unusable token endpoint rejects here rather than as an
// `AuthRecoveryRequiredError`, so without this it is rethrown and lands
// in `runCommandInBackground` — which either shows the raw SDK text
// under a generic title or, at a call site whose panel owns reporting,
// swallows it and leaves the command looking like it did nothing.
//
// Claimed rather than rethrown, taking the same `undefined` exit the
// unsatisfied-recovery branch above already uses: the failure is
// terminal and now fully reported, so an awaited caller should stop
// rather than render it a second time.
const server = sessionRef.current.servers.find(
(s) => s.id === activeServerId,
);
if (
reportTerminalInsecureTokenEndpoint(err, activeServerId, server?.name)
) {
return undefined;
}
throw err;
}
},
[inspectorClient, activeServerId, handleCommandScopedAuthRecovery],
[
inspectorClient,
activeServerId,
handleCommandScopedAuthRecovery,
sessionRef,
reportTerminalInsecureTokenEndpoint,
],
);
/**
@ -924,6 +1000,25 @@ export function useOAuthRecovery({
});
}
} catch (err) {
// SEP-2207 (#2280) first, and specifically BEFORE the restore below.
// `handleAuthChallenge` runs the same SDK auth flow, so it can raise
// this terminal error — and the restore's whole premise is that the
// recovery is still owed and a later trigger should retry it. For a
// refusal that can only fail the same way, re-arming the slot means
// every future tab focus and reconnect replays it, under a toast
// promising a retry that cannot succeed. Report it and let it go.
const failedServer = sessionRef.current.servers.find(
(s) => s.id === pending.serverId,
);
if (
reportTerminalInsecureTokenEndpoint(
err,
pending.serverId,
failedServer?.name,
)
) {
return;
}
// The slot was cleared above only to keep a tab-visible event and a
// reconnect from starting the same authorization twice — not because
// the recovery was delivered. It still is owed, so restore it and let
@ -969,6 +1064,7 @@ export function useOAuthRecovery({
}
},
[
reportTerminalInsecureTokenEndpoint,
sessionRef,
inspectorClient,
connectionStatus,
@ -1320,6 +1416,12 @@ export function useOAuthRecovery({
});
return;
}
// Above `setFailedServerId` for the same reason the EMA arm is: this is
// a configuration error, not a failed attempt, so it should not flag
// the card red or pull the monitoring sidebar open.
if (reportTerminalInsecureTokenEndpoint(err, server.id, server.name)) {
return;
}
// The token exchange (or the re-handshake behind it) failed. Flag the
// server (#1621) so the monitoring sidebar opens onto the OAuth
// requests that explain it (#2108) — the rebuilt client restored the
@ -1424,6 +1526,7 @@ export function useOAuthRecovery({
initialConfigSettledRef,
clearResultPanels,
showReAuthBanner,
reportTerminalInsecureTokenEndpoint,
webOAuthStorage,
setUi,
setActiveTab,

View file

@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { InsecureTokenEndpointError } from "@modelcontextprotocol/client";
import {
insecureTokenEndpointMessage,
insecureTokenEndpointTitle,
} from "../utils/oauthUx";
const show = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (...args: unknown[]) => show(...args) },
}));
const { showInsecureTokenEndpointNotice } =
await import("./insecureTokenEndpointNotice");
const ENDPOINT = "http://tenant.example.localhost:3300/api/oauth/token";
beforeEach(() => {
show.mockClear();
});
describe("showInsecureTokenEndpointNotice", () => {
it("claims the SDK error and shows the terminal notice", () => {
const handled = showInsecureTokenEndpointNotice(
new InsecureTokenEndpointError(ENDPOINT),
"Acme",
);
expect(handled).toBe(true);
expect(show).toHaveBeenCalledTimes(1);
expect(show).toHaveBeenCalledWith({
title: insecureTokenEndpointTitle(),
message: insecureTokenEndpointMessage({
tokenEndpoint: ENDPOINT,
serverName: "Acme",
}),
color: "red",
// Non-recoverable, so the explanation must not vanish on a timer — there
// is no second chance to read it.
autoClose: false,
});
});
it("works without a server name", () => {
expect(
showInsecureTokenEndpointNotice(new InsecureTokenEndpointError(ENDPOINT)),
).toBe(true);
expect(show.mock.calls[0][0].message).toContain("this server");
});
it("declines any other error, leaving the caller's handling in place", () => {
expect(showInsecureTokenEndpointNotice(new Error("boom"), "Acme")).toBe(
false,
);
expect(show).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,59 @@
/**
* Surfaces the SDK's SEP-2207 refusal to post credentials to a non-TLS token
* endpoint as the terminal configuration error it is (#2280).
*
* Lives in `lib/` rather than `utils/` because showing a notification is a side
* effect; the copy it renders is pure and lives in `@inspector/core/auth`.
*
* Shaped as a claim-or-decline predicate rather than a plain `show(...)` so the
* three OAuth failure paths that need it — the connect handshake, the post-
* redirect callback, and the re-auth banner funnel — can each spend one line on
* it and keep their existing fall-through intact:
*
* ```ts
* if (showInsecureTokenEndpointNotice(err, server.name)) return;
* ```
*
* `autoClose: false` matches the other non-recoverable OAuth notices (issuer
* mismatch, unconfigured enterprise IdP): nothing the user does next will make
* this reappear, so a toast that vanishes takes the only explanation with it.
* It stops the notice **expiring**, not the user dismissing it — Mantine's close
* control still works, which is correct for a message someone has finished
* reading. Don't describe this as non-dismissible.
*/
import { notifications } from "@mantine/notifications";
import { findInsecureTokenEndpoint } from "@inspector/core/auth/insecureTokenEndpoint.js";
import {
insecureTokenEndpointMessage,
insecureTokenEndpointTitle,
} from "../utils/oauthUx";
/**
* Show the terminal notice when `err` is the SDK's `InsecureTokenEndpointError`.
*
* @returns `true` when it was handled (the caller should stop), `false` when
* `err` is some other failure and the caller's normal handling applies.
*/
export function showInsecureTokenEndpointNotice(
err: unknown,
serverName?: string,
): boolean {
// Searched rather than type-tested: era negotiation and the transport
// wrappers bury the rejection under `cause` / `data.cause`, so the connect and
// refresh paths hand us a wrapper rather than the error itself.
const found = findInsecureTokenEndpoint(err);
if (!found) {
return false;
}
notifications.show({
title: insecureTokenEndpointTitle(),
message: insecureTokenEndpointMessage({
tokenEndpoint: found.tokenEndpoint,
serverName,
}),
color: "red",
autoClose: false,
});
return true;
}

View file

@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import { InsecureTokenEndpointError } from "@modelcontextprotocol/client";
import { findInsecureTokenEndpoint } from "@inspector/core/auth/insecureTokenEndpoint.js";
const ENDPOINT = "http://tenant.example.localhost:3300/api/oauth/token";
/**
* Documents the assumption the classifier is built on, the same way
* `issuerBinding.test.ts` does for its sibling: the SDK declares `mcpBrand` in
* a `static {}` block, so it lives on the constructor and instances never carry
* it. A classifier that read `err.mcpBrand` would match no real thrown error.
*/
describe("SDK brand placement", () => {
it("keeps `mcpBrand` on the class, not the instance", () => {
expect("mcpBrand" in new InsecureTokenEndpointError(ENDPOINT)).toBe(false);
});
it("is not an OAuthError, which is why the retry path must not claim it", () => {
const err = new InsecureTokenEndpointError(ENDPOINT);
// The SDK deliberately keeps this off the `OAuthError` hierarchy so hosts
// do not treat it as a transient authorization failure. If a future SDK
// changes that, the #2280 handling should be revisited rather than silently
// keeping a now-wrong justification.
expect(err.name).toBe("InsecureTokenEndpointError");
expect(typeof err.tokenEndpoint).toBe("string");
});
});
describe("findInsecureTokenEndpoint", () => {
it("recognizes a real SDK error and returns its endpoint", () => {
expect(
findInsecureTokenEndpoint(new InsecureTokenEndpointError(ENDPOINT)),
).toMatchObject({ tokenEndpoint: ENDPOINT });
});
it("recognizes a serialized copy by `name`, where the prototype is gone", () => {
// The fallback arm: a structured clone or JSON hop drops the prototype and
// the brand set but keeps `name`.
expect(
findInsecureTokenEndpoint({
name: "InsecureTokenEndpointError",
message: "Refusing to send credentials…",
tokenEndpoint: ENDPOINT,
}),
).toMatchObject({ tokenEndpoint: ENDPOINT });
});
it("rejects a look-alike carrying the endpoint but not the identity", () => {
// Neither the brand nor the name: some other error that happens to have a
// `tokenEndpoint` field must not be swallowed by the terminal arm.
expect(
findInsecureTokenEndpoint({ tokenEndpoint: ENDPOINT, name: "Error" }),
).toBeUndefined();
});
it("rejects the right identity with no endpoint to report", () => {
// The copy names the endpoint, so a value that cannot supply one is not
// usable by this path and falls through to the generic handling.
expect(
findInsecureTokenEndpoint({ name: "InsecureTokenEndpointError" }),
).toBeUndefined();
expect(
findInsecureTokenEndpoint({
name: "InsecureTokenEndpointError",
tokenEndpoint: 42,
}),
).toBeUndefined();
});
it.each([null, undefined, "InsecureTokenEndpointError", 0, new Error("x")])(
"rejects %j",
(value) => {
expect(findInsecureTokenEndpoint(value)).toBeUndefined();
},
);
describe("cause chains", () => {
// Era negotiation and the transport wrappers bury the rejection, so a
// top-level-only check would miss the connect and refresh paths outright
// and let the retryable UI render anyway.
it("finds it under `cause`", () => {
const wrapped = new Error("connect failed", {
cause: new InsecureTokenEndpointError(ENDPOINT),
});
expect(findInsecureTokenEndpoint(wrapped)).toMatchObject({
tokenEndpoint: ENDPOINT,
});
});
it("finds it under `data.cause`", () => {
const wrapped = Object.assign(new Error("negotiation failed"), {
data: { cause: new InsecureTokenEndpointError(ENDPOINT) },
});
expect(findInsecureTokenEndpoint(wrapped)).toMatchObject({
tokenEndpoint: ENDPOINT,
});
});
it("finds it several links down", () => {
const wrapped = new Error("outer", {
cause: new Error("middle", {
cause: new InsecureTokenEndpointError(ENDPOINT),
}),
});
expect(findInsecureTokenEndpoint(wrapped)).toMatchObject({
tokenEndpoint: ENDPOINT,
});
});
it("terminates on a self-referential cause instead of looping", () => {
const loop: { cause?: unknown; name: string } = { name: "Loop" };
loop.cause = loop;
expect(findInsecureTokenEndpoint(loop)).toBeUndefined();
});
it("returns undefined for a chain that never contains one", () => {
expect(
findInsecureTokenEndpoint(
new Error("outer", { cause: new Error("inner") }),
),
).toBeUndefined();
});
});
});

View file

@ -6,6 +6,8 @@ import {
emaStepUpFailureMessage,
emaStepUpInProgressMessage,
emaStepUpSuccessMessage,
insecureTokenEndpointMessage,
insecureTokenEndpointTitle,
isActionTriggeredOAuthRecovery,
isEmaStepUp,
isReAuthBannerReason,
@ -437,3 +439,57 @@ describe("oauthUx issuer-binding copy", () => {
});
});
});
describe("insecureTokenEndpoint copy", () => {
const ENDPOINT = "http://tenant.example.localhost:3300/api/oauth/token";
it("names the failure as a configuration problem, not an auth failure", () => {
expect(insecureTokenEndpointTitle()).toBe("Token endpoint is not secure");
});
it("describes the scheme as not-HTTPS rather than as plain HTTP", () => {
// The SDK's check is `protocol !== "https:"`, so a mistyped `ftp:` or `ws:`
// endpoint lands here too; naming the wrong scheme would send the reader
// hunting for a problem they do not have.
const message = insecureTokenEndpointMessage({
tokenEndpoint: "ftp://as.example.com/token",
});
expect(message).toContain("not HTTPS");
expect(message).not.toContain("plain HTTP");
});
it("names the endpoint, the server, and both ways out", () => {
const message = insecureTokenEndpointMessage({
tokenEndpoint: ENDPOINT,
serverName: "Acme",
});
expect(message).toContain('"Acme"');
expect(message).toContain(ENDPOINT);
expect(message).toContain("HTTPS");
expect(message).toContain("127.0.0.1");
expect(message).toContain("Token URL");
});
it("says a retry cannot help, which is the whole point of the message", () => {
// The bug this copy fixes (#2280) was a Re-authenticate button that could
// never succeed. If this sentence goes, the copy stops doing its job.
expect(insecureTokenEndpointMessage({ tokenEndpoint: ENDPOINT })).toContain(
"Re-authenticating cannot change this",
);
});
it("falls back to a generic subject with no server name", () => {
const message = insecureTokenEndpointMessage({ tokenEndpoint: ENDPOINT });
expect(message).toContain("this server");
expect(message).not.toContain('""');
});
it("bounds a hostile-length endpoint for display", () => {
// The endpoint is remote-supplied (it comes from the server's AS metadata),
// so an overlong value must not be echoed back whole into the layout.
const long = `https://example.com/${"a".repeat(500)}`;
const message = insecureTokenEndpointMessage({ tokenEndpoint: long });
expect(message).not.toContain(long);
expect(message).toContain("…");
});
});

View file

@ -0,0 +1,228 @@
import { describe, it, expect, afterEach } from "vitest";
import { createServer, type Server } from "node:http";
import { writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import {
createTestServerHttp,
type TestServerHttp,
createTestServerInfo,
loadConfig,
resolveConfig,
} from "@modelcontextprotocol/inspector-test-server";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Live coverage of `ServerConfig.strictPort` (#2280).
*
* Every other fixture walks to the next free port on `EADDRINUSE`, which is
* right for them and wrong for one: `oauth-insecure-token-endpoint-http.json`
* hard-codes its port inside an OAuth issuer string, so a relocated server would
* announce 8092 while all its metadata still pointed at whatever unrelated
* process holds 8091 — and would silently stop reproducing the refusal it
* exists for. This asserts the walk still happens by default and does not
* happen for that fixture, because "fails loudly" is only a safety property if
* it actually fails.
*/
const configsDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../../test-servers/configs",
);
describe("strictPort (#2280)", () => {
let squatter: Server | null = null;
let server: TestServerHttp | null = null;
afterEach(async () => {
if (server) {
try {
await server.stop();
} catch {
// ignore
}
server = null;
}
if (squatter) {
await new Promise<void>((resolve) => squatter!.close(() => resolve()));
squatter = null;
}
});
/** Hold a port so the next bind has to decide whether to walk. */
const squat = async (): Promise<number> => {
squatter = createServer((_req, res) => res.end());
await new Promise<void>((resolve) =>
squatter!.listen(0, "127.0.0.1", () => resolve()),
);
const address = squatter.address();
if (typeof address !== "object" || address === null) {
throw new Error("no port");
}
return address.port;
};
it("walks to another port by default", async () => {
const taken = await squat();
server = createTestServerHttp({
serverInfo: createTestServerInfo("walks", "1.0.0"),
serverType: "streamable-http",
port: taken,
});
const bound = await server.start();
expect(bound).not.toBe(taken);
});
it("refuses to relocate when strictPort is set", async () => {
const taken = await squat();
server = createTestServerHttp({
serverInfo: createTestServerInfo("strict", "1.0.0"),
serverType: "streamable-http",
port: taken,
strictPort: true,
});
await expect(server.start()).rejects.toMatchObject({
code: "EADDRINUSE",
});
// Deliberately NOT nulled: `start()` installs the process-global test-server
// control before it binds, and only `stop()` clears it. Dropping the
// reference here would skip teardown and leave that global pointing at a
// dead server for the rest of the worker.
});
it.each([undefined, 0])(
"refuses to start with strictPort and port %j",
async (port) => {
// Nothing to be strict about. Falling through to an OS-assigned port
// would let a misconfigured fixture look strict while relocating every
// run — the failure the flag exists to prevent, now silent.
server = createTestServerHttp({
serverInfo: createTestServerInfo("misconfigured", "1.0.0"),
serverType: "streamable-http",
port,
strictPort: true,
});
await expect(server.start()).rejects.toThrow(/integer in 1-65535/);
},
);
it.each(["false", "true", 1, null])(
"rejects a non-boolean strictPort in a config file: %j",
(value) => {
// Consumed as a plain truthiness check at bind time, so the string
// "false" would read as *enabled* and silently disable the port walk —
// the opposite of what the author wrote.
const file = path.join(
tmpdir(),
`strict-port-${Date.now()}-${Math.random()}.json`,
);
writeFileSync(
file,
JSON.stringify({
serverInfo: { name: "x", version: "1.0.0" },
transport: { type: "streamable-http", port: 8099, strictPort: value },
}),
);
try {
expect(() => loadConfig(file)).toThrow(
/transport.strictPort must be a boolean/,
);
} finally {
rmSync(file, { force: true });
}
},
);
it.each([
// Each of these is truthy or type-valid enough to pass a naive check, and
// each fails SILENTLY: the fixture looks strict and relocates anyway.
[
{ type: "streamable-http", port: "0", strictPort: true },
/integer in 1-65535/,
],
[
{ type: "streamable-http", port: "8091", strictPort: true },
/integer in 1-65535/,
],
[
{ type: "streamable-http", port: 0, strictPort: true },
/integer in 1-65535/,
],
[
{ type: "streamable-http", port: 8091.5, strictPort: true },
/integer in 1-65535/,
],
[
{ type: "streamable-http", port: 70000, strictPort: true },
/integer in 1-65535/,
],
[{ type: "streamable-http", strictPort: true }, /integer in 1-65535/],
// No listener at all, and `resolveConfig` drops the flag.
[{ type: "stdio", strictPort: true }, /requires an HTTP transport/],
])("rejects the unhonorable strictPort config %j", (transport, message) => {
const file = path.join(
tmpdir(),
`strict-port-combo-${Date.now()}-${Math.random()}.json`,
);
writeFileSync(
file,
JSON.stringify({
serverInfo: { name: "x", version: "1.0.0" },
transport,
}),
);
try {
expect(() => loadConfig(file)).toThrow(message);
} finally {
rmSync(file, { force: true });
}
});
it("still accepts the honorable combination", () => {
const file = path.join(
tmpdir(),
`strict-port-ok-${Date.now()}-${Math.random()}.json`,
);
writeFileSync(
file,
JSON.stringify({
serverInfo: { name: "x", version: "1.0.0" },
transport: { type: "streamable-http", port: 8091, strictPort: true },
}),
);
try {
expect(resolveConfig(loadConfig(file)).strictPort).toBe(true);
} finally {
rmSync(file, { force: true });
}
});
it("rejects a truthy-but-unbindable port at bind time too", async () => {
// Defense in depth for a programmatic caller that bypasses `loadConfig`.
// A string "0" is truthy, so a bare falsiness guard would pass it through
// and Node would coerce it to the dynamic port 0.
server = createTestServerHttp({
serverInfo: createTestServerInfo("stringy", "1.0.0"),
serverType: "streamable-http",
port: "0" as unknown as number,
strictPort: true,
});
await expect(server.start()).rejects.toThrow(/integer in 1-65535/);
// Deliberately NOT nulled, for the same reason as the EADDRINUSE case
// above: `start()` installs the process-global test-server control before
// it validates, so dropping the reference would skip teardown and leave
// that global pointing at a dead server.
});
it("is carried from the fixture's config file to the resolved server config", async () => {
// The plumbing half: a flag the loader drops would leave the fixture
// relocating again with nothing to show for it.
const resolved = resolveConfig(
loadConfig(
path.join(configsDir, "oauth-insecure-token-endpoint-http.json"),
),
);
expect(resolved.strictPort).toBe(true);
expect(resolved.port).toBe(8091);
expect(resolved.oauth?.issuerUrl?.href).toContain("localhost.:8091");
});
});

View file

@ -14,6 +14,8 @@ export {
lostAuthorizationStateTitle,
issuerMismatchMessage,
issuerMismatchTitle,
insecureTokenEndpointMessage,
insecureTokenEndpointTitle,
type OAuthInteractiveAuthKind,
type OAuthPreRedirectContext,
type OAuthRecoverySource,

View file

@ -0,0 +1,107 @@
/**
* SEP-2207: 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 the
* prototype and brand set are the first things a structured clone or a JSON hop
* would drop, and `name` survives both.
*/
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;
}

View file

@ -299,11 +299,16 @@ export function issuerMismatchTitle(): string {
*/
const MAX_DISPLAYED_ISSUER_LENGTH = 120;
/** Bound a remote-supplied URL for display, marking any truncation. */
export function truncateUrlForDisplay(url: string): string {
return url.length > MAX_DISPLAYED_ISSUER_LENGTH
? `${url.slice(0, MAX_DISPLAYED_ISSUER_LENGTH)}…`
: url;
}
/** Bound an issuer for display, marking any truncation. */
export function truncateIssuerForDisplay(issuer: string): string {
return issuer.length > MAX_DISPLAYED_ISSUER_LENGTH
? `${issuer.slice(0, MAX_DISPLAYED_ISSUER_LENGTH)}…`
: issuer;
return truncateUrlForDisplay(issuer);
}
/**
@ -357,3 +362,53 @@ export function reAuthBannerMessage(options: {
: "Authentication needs attention.";
return options.detail ? `${prefix} ${options.detail}` : prefix;
}
/**
* Heading for the SDK's SEP-2207 refusal to post credentials to a non-TLS token
* endpoint (#2280).
*
* Deliberately not phrased as an authentication failure. Like
* {@link issuerMismatchTitle}, this is offered **no** one-click recovery: the
* SDK rethrows `InsecureTokenEndpointError` rather than retrying, so a
* "Re-authenticate" affordance here could only fail the same way, and a button
* that cannot work is worse than no button.
*/
export function insecureTokenEndpointTitle(): string {
return "Token endpoint is not secure";
}
/**
* Plain-language explanation and the two things that actually resolve it.
*
* Does not echo the SDK's own message, which reads as a flat refusal and tells
* the user nothing about which lever to reach for.
*
* The scheme half says "not HTTPS" rather than "plain HTTP": the SDK's check is
* `protocol !== "https:"`, so anything else an authorization server advertises
* — including a mistyped `ftp:` or `ws:` endpoint — lands here too, and naming
* the wrong scheme would send the reader looking for a problem they do not have.
*
* The host half is "outside the SDK's loopback exemption", never "not loopback".
* The motivating hosts — `tenant.app.localhost` (#1944), the `localhost.`
* fixture — *are* loopback by RFC 6761 and by every resolver on the machine;
* what they are outside is a three-literal allow-list. Calling them non-loopback
* would send a reader to debug their networking instead of their configuration. The endpoint is
* remote-supplied (it comes from the server's authorization-server metadata),
* so it is bounded for display by {@link truncateUrlForDisplay}; rendering is
* escaped, so that is a layout bound rather than an injection defence.
*/
export function insecureTokenEndpointMessage(options: {
tokenEndpoint: string;
serverName?: string;
}): string {
const target = options.serverName ? `"${options.serverName}"` : "this server";
return (
`Authorization for ${target} was stopped before any credentials were sent: ` +
`its token endpoint ${truncateUrlForDisplay(options.tokenEndpoint)} is ` +
"not HTTPS, and its host is outside the MCP SDK's loopback exemption, " +
"which covers only localhost, 127.0.0.1 and ::1. Re-authenticating cannot " +
"change this. Serve the token endpoint over HTTPS, or move it to one of " +
"those three spellings — Server Settings → Authorization has a Token URL " +
"override if the authorization server advertises a different one."
);
}

View file

@ -54,6 +54,7 @@ as a missing capability rather than an error.
| `oauth-custom-resource-metadata-http.json` **(legacy era)** | OAuth discovery driven by the challenge's `resource_metadata` | [#2071](https://github.com/modelcontextprotocol/inspector/issues/2071) |
| `oauth-revocation-http.json` / `oauth-no-revocation-http.json` **(legacy era)** | RFC 7009 token revocation on clear, with and without a `revocation_endpoint` | [#2144](https://github.com/modelcontextprotocol/inspector/issues/2144) |
| `oauth-rfc8414-at-oidc-path-http.json` **(legacy era)** | Plain OAuth 2.0 AS metadata served at the OIDC well-known path | [#2172](https://github.com/modelcontextprotocol/inspector/issues/2172) |
| `oauth-insecure-token-endpoint-http.json` **(legacy era)** | A token endpoint the SDK refuses to post credentials to (SEP-2207) | [#2280](https://github.com/modelcontextprotocol/inspector/issues/2280) |
| `logging-{legacy,modern}-http.json` **(era per file)** | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
| `subscriptions-{legacy,modern}-http.json` **(era per file)** | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
| `subscriptions-never-acknowledged-http.json` **(modern era)** | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) |
@ -454,6 +455,24 @@ The same server is worth running against `--cli` / `--tui`, which reach it by a
The value now rides the normalized `AuthChallenge` as a string — it has to be serializable, because the web client's challenge crosses the remote-backend boundary as JSON — and is converted to a `URL` at the OAuth boundary, where it is handed to `auth()` as `resourceMetadataUrl` and to the CIMD pre-registration probe, which runs *before* `auth()` and would otherwise do its own default-location discovery. A malformed value is ignored rather than surfaced, matching the SDK's own `WWW-Authenticate` parser: discovery falls back to the default locations instead of failing the whole authorization on a bad header. The callback leg needs nothing extra — SDK `auth()` persists the URL in its discovery state, so it survives both the web full-page redirect and the CLI/TUI loopback callback.
## A token endpoint the SDK will not use (SEP-2207)
`oauth-insecure-token-endpoint-http.json` is an ordinary combined AS + resource server with one thing changed: `oauth.issuerUrl` is `http://localhost.:8091`, so its advertised `token_endpoint` is `http://localhost.:8091/oauth/token`. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
⚠️ It sets `transport.strictPort`, so it **fails to start** if 8091 is taken rather than relocating. (`strictPort` is only honorable alongside an HTTP transport and an integer `port` in 1–65535, so `loadConfig` rejects every other combination outright — each of them would otherwise leave a fixture looking strict while relocating anyway.) Every other fixture walks to the next free port on `EADDRINUSE`, which is right for them and wrong for this one: the issuer is a *fixed string* in the config, so a relocated server would announce 8092 while all its OAuth metadata still pointed at whatever unrelated process holds 8091 — and the fixture would quietly stop reproducing the refusal it exists for. A loud failure is the only honest option here.
The trailing dot is the whole trick, and it is doing real work rather than being a curiosity. `localhost.` is the *root-anchored* spelling of `localhost`: every resolver on the machine sends it to the loopback interface, so the fixture is reachable and the flow runs for real — but the SDK's `assertSecureTokenEndpoint` exempts only the three literals `localhost`, `127.0.0.1` and `::1`, and `localhost.` is none of them. So the credential-carrying request is refused with `InsecureTokenEndpointError` while everything else about the server works. It is the same over-narrow exemption that makes `http://tenant.app.localhost:3300` fail ([#1944](https://github.com/modelcontextprotocol/inspector/issues/1944), [typescript-sdk#2591](https://github.com/modelcontextprotocol/typescript-sdk/issues/2591)), reproducible without a `/etc/hosts` entry or dnsmasq.
Add the server, click **Connect**, and complete the authorization. The redirect comes back with a code, the Inspector goes to exchange it, and the SDK refuses.
What you should see is a red, non-expiring **"Token endpoint is not secure"** notification naming the endpoint and the two things that resolve it — serve it over HTTPS, or move it to one of the three host spellings the SDK exempts (`localhost`, `127.0.0.1`, `::1`). It stays until you close it (`autoClose: false` stops it expiring on a timer; Mantine's own close control still dismisses it, which is what you want for a message you have finished reading). There is deliberately **no** action button.
Note the second option is phrased as a *spelling* change, not a networking one. `localhost.` already **is** loopback, and so is `tenant.app.localhost`; what they are outside is a three-literal allow-list. Telling a reader to "use a loopback host" when they demonstrably already are is what sends them off to debug their resolver instead of their configuration.
On the broken build you got a **"Re-authentication required"** banner with a **Re-authenticate** button ([#2280](https://github.com/modelcontextprotocol/inspector/issues/2280)). That button could never work: `InsecureTokenEndpointError` does not extend `OAuthError`, and `auth()` special-cases it to rethrow rather than start a fresh `/authorize` redirect, so clicking it re-ran the same flow to the same refusal. The only text on screen was the raw SDK message, which names the three exempt literals and says nothing about which lever to reach for.
Note that the fix here is presentational only. Making a `*.localhost` token endpoint actually **work** has to land in the SDK — the assertion runs inside `executeTokenRequest`, takes no options, and there is no hook the Inspector could reach.
## Revoking tokens on clear (RFC 7009)
`oauth-revocation-http.json` and `oauth-no-revocation-http.json` are the same OAuth-protected server (combined AS + resource, DCR, refresh tokens) differing in one thing: the first advertises a `revocation_endpoint`, the second advertises none. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.

View file

@ -0,0 +1,27 @@
{
"serverInfo": {
"name": "oauth-insecure-token-endpoint",
"version": "1.0.0"
},
"tools": [
{
"preset": "echo"
}
],
"oauth": {
"enabled": true,
"mode": "combined",
"requireAuth": true,
"scopesSupported": [
"mcp"
],
"supportDCR": true,
"supportRefreshTokens": true,
"issuerUrl": "http://localhost.:8091"
},
"transport": {
"type": "streamable-http",
"port": 8091,
"strictPort": true
}
}

View file

@ -456,6 +456,17 @@ export interface ServerConfig {
| undefined; // Optional callback to customize resource handler during registration
serverType?: "sse" | "streamable-http"; // Transport type (default: "streamable-http")
port?: number; // Port to use (optional, will find available port if not specified)
/**
* Refuse to relocate: bind {@link port} exactly, or fail with EADDRINUSE.
*
* Off by default, because walking to the next free port is what lets several
* fixtures run side by side. It exists for a fixture whose *advertised*
* configuration hard-codes the port — `oauth-insecure-token-endpoint-http.json`
* puts it in an OAuth issuer — where relocating leaves the server announcing
* one port while its metadata still points at another process entirely, and
* the fixture silently stops reproducing what it exists to reproduce.
*/
strictPort?: boolean;
/**
* Whether to advertise listChanged capability for each list type
* If enabled, modification tools will send list_changed notifications

View file

@ -119,6 +119,8 @@ export interface ConfigFile {
transport: {
type: "stdio" | "streamable-http" | "sse";
port?: number;
/** Bind `port` exactly, or fail — see `ServerConfig.strictPort`. */
strictPort?: boolean;
/**
* Serve the modern (2026-07-28) protocol era via the SDK's
* `createMcpHandler` (only valid with `type: "streamable-http"`). `true`
@ -196,6 +198,48 @@ function validateConfig(
`Invalid config in ${filePath}: transport.type must be stdio, streamable-http, or sse`,
);
}
// `strictPort` is consumed as a plain truthiness check at bind time, so a
// string `"false"` would read as *enabled* and silently disable the port walk
// — the opposite of what the author wrote. Validate the type here, where the
// file is being trusted, rather than letting it through as a `ConfigFile`.
if (
transport.strictPort !== undefined &&
typeof transport.strictPort !== "boolean"
) {
throw new Error(
`Invalid config in ${filePath}: transport.strictPort must be a boolean`,
);
}
// Beyond the type: reject every combination that cannot honor the flag's
// contract, because each of them fails *silently* — the fixture looks strict
// and relocates anyway, which is the failure the flag exists to prevent.
//
// - a string port (`"0"`, `"8091"`) is truthy, so it slips past the runtime
// "no port to be strict about" guard, and Node then coerces `"0"` to the
// dynamic port 0;
// - a non-integer or out-of-range port cannot be bound as written;
// - on `stdio` there is no listener at all, and `resolveConfig` drops the
// flag, so it silently does nothing.
if (transport.strictPort === true) {
if (transportType === "stdio") {
throw new Error(
`Invalid config in ${filePath}: transport.strictPort requires an HTTP transport (streamable-http or sse)`,
);
}
const port = transport.port;
if (
typeof port !== "number" ||
!Number.isInteger(port) ||
port < 1 ||
port > 65535
) {
throw new Error(
`Invalid config in ${filePath}: transport.strictPort requires transport.port to be an integer in 1-65535 (got ${JSON.stringify(port)})`,
);
}
}
// Only reject *enabling* modern on a non-HTTP transport; a falsy `modern`
// (e.g. `false`) is a no-op that `resolveConfig` normalizes away.
if (transport.modern && transportType !== "streamable-http") {

View file

@ -102,6 +102,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig {
? (transport.type as "sse" | "streamable-http")
: undefined,
port: isHttp ? transport.port : undefined,
strictPort: isHttp ? transport.strictPort : undefined,
};
// Normalize the modern flag: `true` is shorthand for the default (dual-era

View file

@ -400,9 +400,39 @@ export class TestServerHttp {
const serverType = this.config.serverType ?? "streamable-http";
const requestedPort = this.config.port;
// `strictPort` means "bind exactly this port, or fail". With no fixed port
// to bind there is nothing to be strict about, and falling through to an
// OS-assigned one would let a misconfigured fixture look strict while
// relocating on every run — the exact failure the flag exists to prevent,
// now silent. Reject the combination instead.
if (
this.config.strictPort &&
(typeof requestedPort !== "number" ||
!Number.isInteger(requestedPort) ||
requestedPort < 1 ||
requestedPort > 65535)
) {
// `loadConfig` rejects these for a config file; this covers a
// programmatic caller, and specifically a value that is *truthy* but not
// bindable as written — a string `"0"` slips past a bare falsiness check
// and Node then coerces it to the dynamic port 0, so the fixture looks
// strict and relocates anyway.
throw new Error(
`strictPort requires an explicit port as an integer in 1-65535 (got ${JSON.stringify(requestedPort)}): ` +
"there is nothing to bind strictly otherwise.",
);
}
// If a port is explicitly requested, find an available port starting from that value
// Otherwise, use 0 to let the OS assign an available port
const port = requestedPort ? await findAvailablePort(requestedPort) : 0;
// Otherwise, use 0 to let the OS assign an available port.
// `strictPort` opts out of the walk: bind the requested port or fail loudly
// (see the field's doc comment — a relocated server whose advertised config
// hard-codes the port is worse than one that does not start).
const port = requestedPort
? this.config.strictPort
? requestedPort
: await findAvailablePort(requestedPort)
: 0;
if (serverType === "streamable-http") {
return this.startHttp(port);