Merge pull request #2115 from modelcontextprotocol/v2/fix/2108-oauth-failure-monitor
fix(web): open the monitoring sidebar when a connect fails during OAuth
This commit is contained in:
commit
2b129087a3
5 changed files with 493 additions and 43 deletions
|
|
@ -13,6 +13,8 @@ import {
|
||||||
act,
|
act,
|
||||||
} from "./test/renderWithMantine";
|
} from "./test/renderWithMantine";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||||
|
import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js";
|
||||||
|
|
||||||
// Spy on the toast layer so the progress-notification tests can assert the
|
// Spy on the toast layer so the progress-notification tests can assert the
|
||||||
// show/update calls without mounting Mantine's <Notifications/> portal.
|
// show/update calls without mounting Mantine's <Notifications/> portal.
|
||||||
|
|
@ -48,18 +50,28 @@ const { messageLogClear } = vi.hoisted(() => ({ messageLogClear: vi.fn() }));
|
||||||
vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
|
vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
await importOriginal<typeof import("@inspector/core/mcp/index.js")>();
|
await importOriginal<typeof import("@inspector/core/mcp/index.js")>();
|
||||||
// When set, the next `connect()` rejects with this value (one-shot), so a test
|
// Each armed value makes one `connect()` reject, in FIFO order, so a test can
|
||||||
// can exercise the handshake-failure path. Cleared after it fires.
|
// exercise the handshake-failure path — or a two-connect sequence such as the
|
||||||
let nextConnectRejection: unknown = null;
|
// auth-recovery retry, where the first call rejects with the recovery error
|
||||||
|
// and the retry behind it fails too. A queue rather than a single slot: with
|
||||||
|
// one slot the second arming would overwrite the first, and the retry would
|
||||||
|
// silently succeed.
|
||||||
|
const connectRejections: unknown[] = [];
|
||||||
// Same one-shot arming for the `/oauth/callback` token exchange, so a test can
|
// Same one-shot arming for the `/oauth/callback` token exchange, so a test can
|
||||||
// exercise the callback-leg failure paths (#1808).
|
// exercise the callback-leg failure paths (#1808).
|
||||||
let nextResumeRejection: unknown = null;
|
let nextResumeRejection: unknown = null;
|
||||||
|
// And for `authenticate()`, the pre-redirect OAuth leg (discovery + DCR), so a
|
||||||
|
// test can exercise a failure there — the case that never reaches the "error"
|
||||||
|
// connection status (#2108).
|
||||||
|
let nextAuthenticateRejection: unknown = null;
|
||||||
|
// And for the auth-recovery challenge check, whose *rejection* is a distinct
|
||||||
|
// control-flow decision from its resolving `false` (#2108): a throw is
|
||||||
|
// surfaced as a failed attempt rather than falling through to the redirect.
|
||||||
|
let nextChallengeCheckRejection: unknown = null;
|
||||||
class FakeInspectorClient extends EventTarget {
|
class FakeInspectorClient extends EventTarget {
|
||||||
connect = vi.fn(() => {
|
connect = vi.fn(() => {
|
||||||
if (nextConnectRejection !== null) {
|
if (connectRejections.length > 0) {
|
||||||
const err = nextConnectRejection;
|
return Promise.reject(connectRejections.shift());
|
||||||
nextConnectRejection = null;
|
|
||||||
return Promise.reject(err);
|
|
||||||
}
|
}
|
||||||
return Promise.resolve(undefined);
|
return Promise.resolve(undefined);
|
||||||
});
|
});
|
||||||
|
|
@ -108,7 +120,22 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
|
||||||
}
|
}
|
||||||
return Promise.resolve(undefined);
|
return Promise.resolve(undefined);
|
||||||
});
|
});
|
||||||
checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(true);
|
authenticate = vi.fn(() => {
|
||||||
|
if (nextAuthenticateRejection !== null) {
|
||||||
|
const err = nextAuthenticateRejection;
|
||||||
|
nextAuthenticateRejection = null;
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
});
|
||||||
|
checkAuthChallengeSatisfied = vi.fn(() => {
|
||||||
|
if (nextChallengeCheckRejection !== null) {
|
||||||
|
const err = nextChallengeCheckRejection;
|
||||||
|
nextChallengeCheckRejection = null;
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
return Promise.resolve(true);
|
||||||
|
});
|
||||||
clearOAuthTokens = vi.fn().mockResolvedValue(undefined);
|
clearOAuthTokens = vi.fn().mockResolvedValue(undefined);
|
||||||
}
|
}
|
||||||
const instances: FakeInspectorClient[] = [];
|
const instances: FakeInspectorClient[] = [];
|
||||||
|
|
@ -122,13 +149,22 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
|
||||||
// Test-only handle so the test can grab the live instance and fire events.
|
// Test-only handle so the test can grab the live instance and fire events.
|
||||||
__clientInstances: instances,
|
__clientInstances: instances,
|
||||||
// Test-only: arm the next connect() to reject (handshake-failure path).
|
// Test-only: arm the next connect() to reject (handshake-failure path).
|
||||||
|
// Call it more than once to arm consecutive calls.
|
||||||
__rejectNextConnect: (err: unknown) => {
|
__rejectNextConnect: (err: unknown) => {
|
||||||
nextConnectRejection = err;
|
connectRejections.push(err);
|
||||||
},
|
},
|
||||||
// Test-only: arm the next resumeAfterOAuth() to reject (callback-leg failure).
|
// Test-only: arm the next resumeAfterOAuth() to reject (callback-leg failure).
|
||||||
__rejectNextResumeAfterOAuth: (err: unknown) => {
|
__rejectNextResumeAfterOAuth: (err: unknown) => {
|
||||||
nextResumeRejection = err;
|
nextResumeRejection = err;
|
||||||
},
|
},
|
||||||
|
// Test-only: arm the next authenticate() to reject (pre-redirect OAuth leg).
|
||||||
|
__rejectNextAuthenticate: (err: unknown) => {
|
||||||
|
nextAuthenticateRejection = err;
|
||||||
|
},
|
||||||
|
// Test-only: arm the next checkAuthChallengeSatisfied() to reject.
|
||||||
|
__rejectNextChallengeCheck: (err: unknown) => {
|
||||||
|
nextChallengeCheckRejection = err;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -432,6 +468,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
readResourceState?: { status?: string };
|
readResourceState?: { status?: string };
|
||||||
currentLogLevel?: string;
|
currentLogLevel?: string;
|
||||||
activeTab?: string;
|
activeTab?: string;
|
||||||
|
activeServer?: string;
|
||||||
erroredServerId?: string;
|
erroredServerId?: string;
|
||||||
initializeResult?: { serverInfo: { name: string; version: string } };
|
initializeResult?: { serverInfo: { name: string; version: string } };
|
||||||
onActiveTabChange: (tab: string) => void;
|
onActiveTabChange: (tab: string) => void;
|
||||||
|
|
@ -513,6 +550,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
<span data-testid="errored-server">
|
<span data-testid="errored-server">
|
||||||
{props.erroredServerId ?? "none"}
|
{props.erroredServerId ?? "none"}
|
||||||
</span>
|
</span>
|
||||||
|
<span data-testid="active-server">{props.activeServer ?? "none"}</span>
|
||||||
<button onClick={() => props.onActiveTabChange("Servers")}>
|
<button onClick={() => props.onActiveTabChange("Servers")}>
|
||||||
switch-servers-tab
|
switch-servers-tab
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -682,15 +720,24 @@ const clientInstances = (
|
||||||
McpIndex as unknown as { __clientInstances: EventTarget[] }
|
McpIndex as unknown as { __clientInstances: EventTarget[] }
|
||||||
).__clientInstances;
|
).__clientInstances;
|
||||||
|
|
||||||
const rejectNextConnect = (
|
// The mock factory adds four test-only arming hooks to the module namespace.
|
||||||
McpIndex as unknown as { __rejectNextConnect: (err: unknown) => void }
|
// Intersecting with `typeof McpIndex` keeps the real module's shape checked and
|
||||||
).__rejectNextConnect;
|
// narrows this to a single cast — `as unknown as` would discard the former and
|
||||||
|
// is what AGENTS.md rules out.
|
||||||
|
type ArmingHooks = typeof McpIndex & {
|
||||||
|
__rejectNextConnect: (err: unknown) => void;
|
||||||
|
__rejectNextResumeAfterOAuth: (err: unknown) => void;
|
||||||
|
__rejectNextAuthenticate: (err: unknown) => void;
|
||||||
|
__rejectNextChallengeCheck: (err: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
const rejectNextResumeAfterOAuth = (
|
const { __rejectNextConnect: rejectNextConnect } = McpIndex as ArmingHooks;
|
||||||
McpIndex as unknown as {
|
const { __rejectNextResumeAfterOAuth: rejectNextResumeAfterOAuth } =
|
||||||
__rejectNextResumeAfterOAuth: (err: unknown) => void;
|
McpIndex as ArmingHooks;
|
||||||
}
|
const { __rejectNextAuthenticate: rejectNextAuthenticate } =
|
||||||
).__rejectNextResumeAfterOAuth;
|
McpIndex as ArmingHooks;
|
||||||
|
const { __rejectNextChallengeCheck: rejectNextChallengeCheck } =
|
||||||
|
McpIndex as ArmingHooks;
|
||||||
|
|
||||||
const fetchLogInstances = (
|
const fetchLogInstances = (
|
||||||
FetchLogModule as unknown as { __fetchLogInstances: EventTarget[] }
|
FetchLogModule as unknown as { __fetchLogInstances: EventTarget[] }
|
||||||
|
|
@ -716,6 +763,101 @@ describe("App failed-connection card border (#1621)", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An OAuth leg that fails never reaches the `"error"` connection status — the
|
||||||
|
// handler tears the client down, leaving the session `"disconnected"` — so the
|
||||||
|
// flag is the only signal downstream that a connect attempt died. Without it
|
||||||
|
// the monitoring sidebar stays shut on exactly the failure the Network tab
|
||||||
|
// exists to explain (#2108).
|
||||||
|
it("flags the server when the OAuth authorization leg fails (#2108)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<App />);
|
||||||
|
expect(screen.getByTestId("errored-server")).toHaveTextContent("none");
|
||||||
|
|
||||||
|
// 401 from the server -> App starts the authorization-code flow, whose
|
||||||
|
// discovery/DCR round-trip then fails (e.g. an invalid
|
||||||
|
// `/.well-known/oauth-authorization-server`).
|
||||||
|
rejectNextConnect(Object.assign(new Error("HTTP 401"), { status: 401 }));
|
||||||
|
rejectNextAuthenticate(new Error("discovery failed"));
|
||||||
|
await user.click(screen.getByText("connect"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
|
||||||
|
);
|
||||||
|
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: expect.stringContaining("OAuth authorization failed"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The auth-recovery arm re-connects from inside the outer `catch`, so before
|
||||||
|
// #2108 a rejection there escaped `onToggleConnection` entirely as an
|
||||||
|
// unhandled rejection: no toast, no red border, no sidebar.
|
||||||
|
it("flags the server when the satisfied-challenge retry fails (#2108)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<App />);
|
||||||
|
|
||||||
|
// First connect asks for recovery; the fake reports the challenge already
|
||||||
|
// satisfied, so App retries the connect — and that retry fails.
|
||||||
|
rejectNextConnect(
|
||||||
|
new AuthRecoveryRequiredError(
|
||||||
|
new URL("https://as.example.com/authorize"),
|
||||||
|
{
|
||||||
|
reason: "unauthorized",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
rejectNextConnect(new Error("handshake failed after re-authorization"));
|
||||||
|
await user.click(screen.getByText("connect"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
|
||||||
|
);
|
||||||
|
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: expect.stringContaining("Failed to connect"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The other half of the guarded arm. A *rejecting* challenge check is not the
|
||||||
|
// same as one that resolves `false`: the latter means "re-authorize", the
|
||||||
|
// former means the check itself broke, so it is surfaced rather than used as
|
||||||
|
// grounds to navigate the whole page to the auth server.
|
||||||
|
it("surfaces a rejecting challenge check instead of redirecting (#2108)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<App />);
|
||||||
|
|
||||||
|
rejectNextConnect(
|
||||||
|
new AuthRecoveryRequiredError(
|
||||||
|
new URL("https://as.example.com/authorize"),
|
||||||
|
{ reason: "unauthorized" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
rejectNextChallengeCheck(new Error("challenge check failed"));
|
||||||
|
await user.click(screen.getByText("connect"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
|
||||||
|
);
|
||||||
|
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: expect.stringContaining("Failed to connect"),
|
||||||
|
message: "challenge check failed",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// No redirect was prepared: `prepareOAuthRedirect` persists a resume
|
||||||
|
// snapshot before navigating, and nothing wrote one.
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
// And the attempt was ended. The outer connect rejected with an
|
||||||
|
// auth-recovery error, which holds the status at "connecting", so without
|
||||||
|
// an explicit teardown here the toggle would spin forever.
|
||||||
|
const client = clientInstances[0] as EventTarget & {
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
expect(client.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("clears the flag when a new connection attempt starts", async () => {
|
it("clears the flag when a new connection attempt starts", async () => {
|
||||||
// Report status "error" so the *second* connect click is treated as a fresh
|
// Report status "error" so the *second* connect click is treated as a fresh
|
||||||
// attempt (not a disconnect of a live session), exercising the clear.
|
// attempt (not a disconnect of a live session), exercising the clear.
|
||||||
|
|
@ -2454,6 +2596,92 @@ describe("App OAuth callback issuer-binding failures (#1808)", () => {
|
||||||
expect(screen.queryByText("Re-authentication required")).toBeNull();
|
expect(screen.queryByText("Re-authentication required")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The callback leg is a connect attempt too, so a failure there must flag the
|
||||||
|
// server — that is what opens the monitoring sidebar onto the OAuth requests
|
||||||
|
// (discovery, DCR and the token exchange, restored from the pre-redirect
|
||||||
|
// session) rather than leaving the user with only a banner (#2108).
|
||||||
|
it("flags the server when the callback leg fails (#2108)", async () => {
|
||||||
|
renderCallbackWithFailure(new Error("token exchange rejected"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
|
||||||
|
);
|
||||||
|
// The active server is deliberately retained. "Authorize again" on the
|
||||||
|
// re-auth banner only hands `clearServerOAuthState` the live client when
|
||||||
|
// the banner's server is the active one, so releasing it here would leave
|
||||||
|
// the stale tokens on the client that holds them. Asserted so a later
|
||||||
|
// "cleanup" of the apparent leak fails here rather than silently breaking
|
||||||
|
// the recovery.
|
||||||
|
expect(screen.getByTestId("active-server")).toHaveTextContent("A");
|
||||||
|
// The teardown still runs, for the case where the reconnect is what
|
||||||
|
// rejected and the status really is left at "connecting".
|
||||||
|
const client = clientInstances[0] as EventTarget & {
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
await waitFor(() => expect(client.disconnect).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
// The callback leg can also die before the token exchange, if the persisted
|
||||||
|
// OAuth state cannot be read back at all. Spied on the prototype rather than
|
||||||
|
// mocked at the module, so every other test keeps the real storage.
|
||||||
|
it("flags the server when the OAuth storage fails to load (#2108)", async () => {
|
||||||
|
const loadSpy = vi
|
||||||
|
.spyOn(RemoteOAuthStorage.prototype, "load")
|
||||||
|
.mockRejectedValueOnce(new Error("storage unavailable"));
|
||||||
|
try {
|
||||||
|
writeOAuthResumeSnapshot({
|
||||||
|
version: 1,
|
||||||
|
serverId: "A",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "reauth",
|
||||||
|
tabUi: {},
|
||||||
|
});
|
||||||
|
window.history.replaceState({}, "", `${OAUTH_CALLBACK_PATH}?code=test`);
|
||||||
|
renderWithMantine(<App />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
|
||||||
|
);
|
||||||
|
// Red border only, by design: this arm returns before a client is built,
|
||||||
|
// so nothing restores the Network log — and a failed local read of
|
||||||
|
// `oauth.json` would not be in it anyway.
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/storage unavailable/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
loadSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The other callback arm: the provider redirected back with an error instead
|
||||||
|
// of a code, so no token exchange is even attempted. Still a connect attempt
|
||||||
|
// that failed, so the server carries the same flag (#2108).
|
||||||
|
//
|
||||||
|
// The flag is deliberately all this asserts. That arm returns before a client
|
||||||
|
// is rebuilt, so no Network entries are restored and the content-gated column
|
||||||
|
// stays shut — which is right here: the provider's `error` param is the whole
|
||||||
|
// diagnostic and the re-auth banner is already showing it. Asserting a
|
||||||
|
// sidebar would be asserting behavior this arm should not have.
|
||||||
|
it("flags the server when the provider returns an error to the callback (#2108)", async () => {
|
||||||
|
writeOAuthResumeSnapshot({
|
||||||
|
version: 1,
|
||||||
|
serverId: "A",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "reauth",
|
||||||
|
tabUi: {},
|
||||||
|
});
|
||||||
|
window.history.replaceState(
|
||||||
|
{},
|
||||||
|
"",
|
||||||
|
`${OAUTH_CALLBACK_PATH}?error=access_denied`,
|
||||||
|
);
|
||||||
|
renderWithMantine(<App />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("clears the stale OAuth state and reconnects when the affordance is used", async () => {
|
it("clears the stale OAuth state and reconnects when the affordance is used", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderCallbackWithFailure(
|
renderCallbackWithFailure(
|
||||||
|
|
|
||||||
|
|
@ -422,20 +422,41 @@ const ToastLinkButton = Anchor.withProps({
|
||||||
size: "sm",
|
size: "sm",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sticky re-auth banner bar. A `Paper` so every static style is a prop: `shadow`
|
// The re-auth popup. A `Paper` so every static style is a prop; the stacking
|
||||||
// emits `var(--mantine-shadow-sm)` (identical to the old CSS), and the stacking
|
|
||||||
// order goes through `styles.root` since Mantine has no `z` prop.
|
// order goes through `styles.root` since Mantine has no `z` prop.
|
||||||
|
//
|
||||||
|
// Floats top-right rather than spanning the top as a sticky full-bleed bar.
|
||||||
|
// The bar cost the whole view a band of vertical space for what is a
|
||||||
|
// notification about one server, and it sat directly above the monitoring
|
||||||
|
// sidebar that an OAuth failure now opens (#2108) — the two things a user needs
|
||||||
|
// at once here are this affordance and those requests, so it must not push them
|
||||||
|
// around. `fixed`, not `sticky`, so scrolling the server list leaves it put.
|
||||||
|
//
|
||||||
|
// Centered, and deliberately WITHOUT an overlay. Every corner is spoken for —
|
||||||
|
// the toast stack owns bottom-right (see `main.tsx`), the right edge is the
|
||||||
|
// monitoring sidebar whose toolbar this would cover, and top-left is the
|
||||||
|
// Servers header — so anchoring it anywhere hides something. Centering is the
|
||||||
|
// one placement that reads as addressed to the whole window rather than
|
||||||
|
// attached to the wrong panel.
|
||||||
|
//
|
||||||
|
// The missing overlay is the point, not an omission: this is a notification,
|
||||||
|
// not a decision that must be made now. An OAuth failure opens the monitoring
|
||||||
|
// sidebar (#2108), and blocking the page would force a choice between reading
|
||||||
|
// those requests and keeping the affordance — dismissing is not free, since
|
||||||
|
// "Authorize again" also clears the stale OAuth state, which a plain reconnect
|
||||||
|
// does not do. So it floats above the page and leaves it usable.
|
||||||
|
//
|
||||||
|
// `transform` goes through `styles.root` for the same reason `zIndex` does:
|
||||||
|
// Mantine exposes neither as a style prop.
|
||||||
const ReAuthBannerBar = Paper.withProps({
|
const ReAuthBannerBar = Paper.withProps({
|
||||||
px: "md",
|
pos: "fixed",
|
||||||
pt: "xs",
|
top: "50%",
|
||||||
pos: "sticky",
|
left: "50%",
|
||||||
top: 60,
|
w: 420,
|
||||||
bg: "var(--mantine-color-body)",
|
bg: "var(--mantine-color-body)",
|
||||||
shadow: "sm",
|
shadow: "xl",
|
||||||
styles: { root: { zIndex: 200 } },
|
radius: "md",
|
||||||
// Paper's default `radius: "md"` would round this full-bleed sticky bar's
|
styles: { root: { transform: "translate(-50%, -50%)", zIndex: 200 } },
|
||||||
// corners; the bar it replaced (a Box) had none.
|
|
||||||
radius: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Body of the "response body dropped" warning toast: a one-line summary of what
|
// Body of the "response body dropped" warning toast: a one-line summary of what
|
||||||
|
|
@ -2841,6 +2862,13 @@ function App() {
|
||||||
if (!params.successful) {
|
if (!params.successful) {
|
||||||
const pendingId = resumeSnapshot?.serverId;
|
const pendingId = resumeSnapshot?.serverId;
|
||||||
if (pendingId) {
|
if (pendingId) {
|
||||||
|
// Red border only (#1621), not a sidebar. This arm returns before a
|
||||||
|
// client is rebuilt, so the persisted `auth` entries are never
|
||||||
|
// restored and the content-gated column stays shut — correctly: the
|
||||||
|
// provider's own `error` param is the whole diagnostic, and the
|
||||||
|
// re-auth banner below is already showing it. The flag is still right,
|
||||||
|
// because the attempt did fail.
|
||||||
|
setFailedServerId(pendingId);
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
showReAuthBanner(pendingId, generateOAuthErrorDescription(params));
|
showReAuthBanner(pendingId, generateOAuthErrorDescription(params));
|
||||||
});
|
});
|
||||||
|
|
@ -2876,6 +2904,10 @@ function App() {
|
||||||
await webOAuthStorage.load();
|
await webOAuthStorage.load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
connectStartRef.current = undefined;
|
connectStartRef.current = undefined;
|
||||||
|
// Red border only, for the same reason as the provider-error arm above
|
||||||
|
// — and here the network log would not help anyway: this is a failure
|
||||||
|
// to read local OAuth storage, not a request that went out.
|
||||||
|
setFailedServerId(server.id);
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
showReAuthBanner(server.id, err instanceof Error ? err : String(err));
|
showReAuthBanner(server.id, err instanceof Error ? err : String(err));
|
||||||
});
|
});
|
||||||
|
|
@ -2891,6 +2923,26 @@ function App() {
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
connectStartRef.current = undefined;
|
connectStartRef.current = undefined;
|
||||||
|
// `resumeAfterOAuth` carries the reconnect, and that reconnect can
|
||||||
|
// reject with an auth-recovery error — which holds the status at
|
||||||
|
// `"connecting"` instead of moving it to `"error"`. Nothing downstream
|
||||||
|
// of here ends the attempt, so without this the toggle is stuck and the
|
||||||
|
// active-server lock is never released. Before the EMA guard, since a
|
||||||
|
// stuck session is worth clearing whichever way the error classifies.
|
||||||
|
await client.disconnect().catch(() => {});
|
||||||
|
// `activeServerId` is deliberately NOT cleared here, even though the
|
||||||
|
// disconnect above often cannot announce itself: it emits only on a
|
||||||
|
// status *change*, and the commonest failure — a rejected token
|
||||||
|
// exchange — throws inside `completeOAuthFlow` before the reconnect
|
||||||
|
// runs, so this freshly built client is still at its initial
|
||||||
|
// `"disconnected"` and the listener that would clear it never fires.
|
||||||
|
//
|
||||||
|
// That looks like a leak and is not. The next step after a callback
|
||||||
|
// failure is the re-auth banner below, and its "Authorize again" hands
|
||||||
|
// `clearServerOAuthState` the live client only when the banner's server
|
||||||
|
// *is* the active one. Releasing it here would pass `null` instead, and
|
||||||
|
// the stale tokens would never be cleared from the client that holds
|
||||||
|
// them — the one thing that recovery exists to do.
|
||||||
if (isEmaClientNotConfiguredError(err)) {
|
if (isEmaClientNotConfiguredError(err)) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: `Cannot connect to "${server.name}"`,
|
title: `Cannot connect to "${server.name}"`,
|
||||||
|
|
@ -2900,6 +2952,19 @@ function App() {
|
||||||
});
|
});
|
||||||
return;
|
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
|
||||||
|
// pre-redirect `auth` fetch entries from the session, so discovery,
|
||||||
|
// DCR and the token exchange are all there.
|
||||||
|
//
|
||||||
|
// Below the EMA guard, not above it: an unconfigured enterprise client
|
||||||
|
// is a *configuration* error rather than a failed attempt, and both
|
||||||
|
// connect-path arms already return on it without flagging. Flagging it
|
||||||
|
// only here would make the three disagree about what the red border
|
||||||
|
// means. Above every other arm, so the classification fan-out that
|
||||||
|
// follows carries it whichever way it goes.
|
||||||
|
setFailedServerId(server.id);
|
||||||
// SEP-2352 issuer binding (#1808). Two very different failures share
|
// SEP-2352 issuer binding (#1808). Two very different failures share
|
||||||
// one SDK error class, so classify before falling through to the
|
// one SDK error class, so classify before falling through to the
|
||||||
// generic re-auth banner (whose detail line would otherwise be the raw
|
// generic re-auth banner (whose detail line would otherwise be the raw
|
||||||
|
|
@ -3048,9 +3113,42 @@ function App() {
|
||||||
// against the right client. The redirect unloads this page, so there's
|
// against the right client. The redirect unloads this page, so there's
|
||||||
// nothing to do after the await on the success path.
|
// nothing to do after the await on the success path.
|
||||||
if (err instanceof AuthRecoveryRequiredError) {
|
if (err instanceof AuthRecoveryRequiredError) {
|
||||||
if (await client.checkAuthChallengeSatisfied(err.authChallenge)) {
|
try {
|
||||||
connectStartRef.current = Date.now();
|
if (await client.checkAuthChallengeSatisfied(err.authChallenge)) {
|
||||||
await client.connect();
|
connectStartRef.current = Date.now();
|
||||||
|
await client.connect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (recoveryErr) {
|
||||||
|
// Both awaits above are unguarded connect work sitting inside a
|
||||||
|
// `catch`, so a rejection escapes `onToggleConnection` altogether:
|
||||||
|
// no toast, no red border, no sidebar — the #2108 failure mode in
|
||||||
|
// its most invisible form. Surface it as the failed connect attempt
|
||||||
|
// it is. A throw from `checkAuthChallengeSatisfied` lands here too
|
||||||
|
// rather than falling through to `prepareOAuthRedirect`: it is not
|
||||||
|
// the same as the challenge being *unsatisfied*, and navigating the
|
||||||
|
// whole page away on the strength of an error would bury it.
|
||||||
|
connectStartRef.current = undefined;
|
||||||
|
// Tear the session down before reporting, as the sibling OAuth
|
||||||
|
// catch below does. The outer `connect()` rejected with an
|
||||||
|
// auth-recovery error, which deliberately holds the status at
|
||||||
|
// `"connecting"` rather than moving it to `"error"` — so if the
|
||||||
|
// challenge check is what rejected, nothing else ever ends the
|
||||||
|
// attempt and the toggle spins while the active-server lock is
|
||||||
|
// held. The fetch log survives a disconnect, so the Network
|
||||||
|
// diagnostics this issue is about are unaffected.
|
||||||
|
await client.disconnect().catch(() => {});
|
||||||
|
setFailedServerId(id);
|
||||||
|
const message =
|
||||||
|
recoveryErr instanceof Error
|
||||||
|
? recoveryErr.message
|
||||||
|
: String(recoveryErr);
|
||||||
|
setConnectErrorMessage(message);
|
||||||
|
notifications.show({
|
||||||
|
title: `Failed to connect to "${target.name}"`,
|
||||||
|
message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
prepareOAuthRedirect({
|
prepareOAuthRedirect({
|
||||||
|
|
@ -3091,6 +3189,13 @@ function App() {
|
||||||
});
|
});
|
||||||
return;
|
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
|
||||||
|
// leg never reaches the `"error"` connection status (the
|
||||||
|
// `disconnect()` above settles it at `"disconnected"`), so this
|
||||||
|
// flag is the only signal the view has that a connect attempt died.
|
||||||
|
setFailedServerId(id);
|
||||||
const message =
|
const message =
|
||||||
authErr instanceof Error ? authErr.message : String(authErr);
|
authErr instanceof Error ? authErr.message : String(authErr);
|
||||||
setConnectErrorMessage(message);
|
setConnectErrorMessage(message);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Alert, Button, Group, Text } from "@mantine/core";
|
import { Alert, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
export const DEFAULT_REAUTH_BANNER_TITLE = "Re-authentication required";
|
export const DEFAULT_REAUTH_BANNER_TITLE = "Re-authentication required";
|
||||||
export const DEFAULT_REAUTH_BANNER_ACTION_LABEL = "Re-authenticate";
|
export const DEFAULT_REAUTH_BANNER_ACTION_LABEL = "Re-authenticate";
|
||||||
|
|
@ -24,11 +24,16 @@ const ReAuthAlert = Alert.withProps({
|
||||||
closeButtonLabel: "Dismiss",
|
closeButtonLabel: "Dismiss",
|
||||||
});
|
});
|
||||||
|
|
||||||
const BannerRow = Group.withProps({
|
// Message above the action rather than beside it: this renders in a ~420px
|
||||||
justify: "space-between",
|
// popup, where a side-by-side row squeezes the message into a few words per
|
||||||
align: "center",
|
// line while the button keeps its full width.
|
||||||
wrap: "nowrap",
|
const BannerBody = Stack.withProps({
|
||||||
gap: "md",
|
gap: "xs",
|
||||||
|
});
|
||||||
|
|
||||||
|
const BannerActions = Group.withProps({
|
||||||
|
justify: "flex-end",
|
||||||
|
gap: "xs",
|
||||||
});
|
});
|
||||||
|
|
||||||
const MessageText = Text.withProps({
|
const MessageText = Text.withProps({
|
||||||
|
|
@ -50,10 +55,12 @@ export function ReAuthBanner({
|
||||||
}: ReAuthBannerProps) {
|
}: ReAuthBannerProps) {
|
||||||
return (
|
return (
|
||||||
<ReAuthAlert title={title} onClose={onDismiss}>
|
<ReAuthAlert title={title} onClose={onDismiss}>
|
||||||
<BannerRow>
|
<BannerBody>
|
||||||
<MessageText>{message}</MessageText>
|
<MessageText>{message}</MessageText>
|
||||||
<ReAuthButton onClick={onReauthenticate}>{actionLabel}</ReAuthButton>
|
<BannerActions>
|
||||||
</BannerRow>
|
<ReAuthButton onClick={onReauthenticate}>{actionLabel}</ReAuthButton>
|
||||||
|
</BannerActions>
|
||||||
|
</BannerBody>
|
||||||
</ReAuthAlert>
|
</ReAuthAlert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1689,6 +1689,87 @@ describe("InspectorView", () => {
|
||||||
expect(screen.queryByRole("radio", { name: "Console" })).toBeNull();
|
expect(screen.queryByRole("radio", { name: "Console" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An OAuth failure is torn down by the parent, so the session rests at
|
||||||
|
// "disconnected" rather than "error" — the status gate the failure column
|
||||||
|
// used to carry never saw it, and the requests explaining the failure were
|
||||||
|
// unreachable (#2108). The parent's `erroredServerId` is the signal instead.
|
||||||
|
it("opens the monitoring sidebar to Network when OAuth fails without an error status (#2108)", async () => {
|
||||||
|
const { rerender } = renderWithMantine(
|
||||||
|
<StatefulInspectorViewHost
|
||||||
|
{...makeProps({
|
||||||
|
servers: [httpServer],
|
||||||
|
activeServer: "beta",
|
||||||
|
connectionStatus: "connecting",
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<StatefulInspectorViewHost
|
||||||
|
{...makeProps({
|
||||||
|
servers: [httpServer],
|
||||||
|
// The failure's teardown clears the active server and settles the
|
||||||
|
// session at "disconnected" — never "error".
|
||||||
|
activeServer: undefined,
|
||||||
|
erroredServerId: "beta",
|
||||||
|
connectionStatus: "disconnected",
|
||||||
|
initializeResult: undefined,
|
||||||
|
network: [
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
timestamp: new Date(),
|
||||||
|
method: "GET",
|
||||||
|
url: "https://as.example.com/.well-known/oauth-authorization-server",
|
||||||
|
requestHeaders: {},
|
||||||
|
responseStatus: 404,
|
||||||
|
responseStatusText: "Not Found",
|
||||||
|
category: "auth",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", { name: "Close monitoring sidebar" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("radio", { name: "Network" })).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-open the sidebar on a mount that starts already flagged (#2108)", () => {
|
||||||
|
// Same props as the failure above, but as the *initial* render: there is
|
||||||
|
// no transition, so a user who closed the column isn't fought on remount.
|
||||||
|
renderWithMantine(
|
||||||
|
<StatefulInspectorViewHost
|
||||||
|
{...makeProps({
|
||||||
|
servers: [httpServer],
|
||||||
|
activeServer: undefined,
|
||||||
|
erroredServerId: "beta",
|
||||||
|
connectionStatus: "disconnected",
|
||||||
|
initializeResult: undefined,
|
||||||
|
network: [
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
timestamp: new Date(),
|
||||||
|
method: "GET",
|
||||||
|
url: "https://as.example.com/.well-known/oauth-authorization-server",
|
||||||
|
requestHeaders: {},
|
||||||
|
responseStatus: 404,
|
||||||
|
responseStatusText: "Not Found",
|
||||||
|
category: "auth",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces Console (stderr), not Network, in the failure column for a stdio server (#1621)", async () => {
|
it("surfaces Console (stderr), not Network, in the failure column for a stdio server (#1621)", async () => {
|
||||||
const stdioErr: ServerEntry = {
|
const stdioErr: ServerEntry = {
|
||||||
id: "beta",
|
id: "beta",
|
||||||
|
|
|
||||||
|
|
@ -388,6 +388,13 @@ export interface InspectorViewProps {
|
||||||
* the Servers screen draws a red border until another server is connected or
|
* the Servers screen draws a red border until another server is connected or
|
||||||
* a new connection is attempted. Independent of `activeServer`, which the
|
* a new connection is attempted. Independent of `activeServer`, which the
|
||||||
* parent clears on the failure's `disconnect` event.
|
* parent clears on the failure's `disconnect` event.
|
||||||
|
*
|
||||||
|
* It is also the sole signal that opens the monitoring sidebar onto the
|
||||||
|
* failure's diagnostics (#2108). That used to be gated on the `"error"`
|
||||||
|
* connection status, which an OAuth failure never reaches — the parent drives
|
||||||
|
* that leg and tears the client down itself, settling the session at
|
||||||
|
* `"disconnected"`. So the parent must set this for *every* connect-attempt
|
||||||
|
* failure, auth legs included, and clear it as each new attempt starts.
|
||||||
*/
|
*/
|
||||||
erroredServerId?: string;
|
erroredServerId?: string;
|
||||||
/**
|
/**
|
||||||
|
|
@ -817,18 +824,33 @@ export function InspectorView({
|
||||||
// connecting/disconnected → error, never connected → error. This keeps the
|
// connecting/disconnected → error, never connected → error. This keeps the
|
||||||
// auto-open aligned with the red-border (`erroredServerId`), which the parent
|
// auto-open aligned with the red-border (`erroredServerId`), which the parent
|
||||||
// also sets only for connect-attempt failures.
|
// also sets only for connect-attempt failures.
|
||||||
|
//
|
||||||
|
// A connect attempt that fails *during OAuth* (#2108) never reaches the
|
||||||
|
// `"error"` status at all: the auth leg is driven from the parent, which
|
||||||
|
// tears the client down on failure, so the session settles at
|
||||||
|
// `"disconnected"`. So the failure arm also fires on the parent raising
|
||||||
|
// `erroredServerId` — the canonical "this server's last connect attempt
|
||||||
|
// failed" flag, which the parent clears at the start of every new attempt.
|
||||||
|
// Tracked through a ref for the same reason as the status: a mount that
|
||||||
|
// starts already flagged must not re-open a column the user closed.
|
||||||
const prevStatusRef = useRef(connectionStatus);
|
const prevStatusRef = useRef(connectionStatus);
|
||||||
|
const prevErroredServerIdRef = useRef(erroredServerId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prev = prevStatusRef.current;
|
const prev = prevStatusRef.current;
|
||||||
prevStatusRef.current = connectionStatus;
|
prevStatusRef.current = connectionStatus;
|
||||||
|
const prevErrored = prevErroredServerIdRef.current;
|
||||||
|
prevErroredServerIdRef.current = erroredServerId;
|
||||||
const becameConnected =
|
const becameConnected =
|
||||||
connectionStatus === "connected" && prev !== "connected";
|
connectionStatus === "connected" && prev !== "connected";
|
||||||
const becameError =
|
const becameError =
|
||||||
connectionStatus === "error" && prev !== "error" && prev !== "connected";
|
(connectionStatus === "error" &&
|
||||||
|
prev !== "error" &&
|
||||||
|
prev !== "connected") ||
|
||||||
|
(erroredServerId !== undefined && prevErrored !== erroredServerId);
|
||||||
if (becameConnected || becameError) {
|
if (becameConnected || becameError) {
|
||||||
setMonitorPinned(true);
|
setMonitorPinned(true);
|
||||||
}
|
}
|
||||||
}, [connectionStatus, setMonitorPinned]);
|
}, [connectionStatus, erroredServerId, setMonitorPinned]);
|
||||||
|
|
||||||
const appTools = useMemo<Tool[]>(() => {
|
const appTools = useMemo<Tool[]>(() => {
|
||||||
return tools.filter((tool) => {
|
return tools.filter((tool) => {
|
||||||
|
|
@ -924,7 +946,14 @@ export function InspectorView({
|
||||||
// (set by the parent only for connect-attempt failures, not mid-session
|
// (set by the parent only for connect-attempt failures, not mid-session
|
||||||
// crashes) so a crash of a previously-connected server doesn't reorganize the
|
// crashes) so a crash of a previously-connected server doesn't reorganize the
|
||||||
// column into the failure tab set — matching the auto-open effect above.
|
// column into the failure tab set — matching the auto-open effect above.
|
||||||
const failed = connectionStatus === "error" && erroredServerId !== undefined;
|
//
|
||||||
|
// Keyed on `erroredServerId` alone rather than on the `"error"` status
|
||||||
|
// (#2108): an OAuth leg that fails is torn down by the parent, so the session
|
||||||
|
// rests at `"disconnected"` and a status gate would never see the failure —
|
||||||
|
// which is exactly the case a server developer needs the Network tab for.
|
||||||
|
// `!connected` is the only status condition left, and it holds vacuously
|
||||||
|
// whenever the flag is set (the parent clears it as each new attempt starts).
|
||||||
|
const failed = !connected && erroredServerId !== undefined;
|
||||||
const monitorAvailable = useMemo<string[]>(() => {
|
const monitorAvailable = useMemo<string[]>(() => {
|
||||||
if (connected) return availableTabs.filter((t) => MONITOR_TABS.includes(t));
|
if (connected) return availableTabs.filter((t) => MONITOR_TABS.includes(t));
|
||||||
if (failed) {
|
if (failed) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue