All of the mid-session and step-up auth work

This commit is contained in:
Bob Dickinson 2026-07-02 16:35:31 -07:00
commit 562bdd00f1
40 changed files with 11437 additions and 1212 deletions

View file

@ -75,6 +75,8 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
getRoots = vi.fn().mockReturnValue([]);
setRoots = vi.fn().mockResolvedValue(undefined);
setServerSettings = vi.fn();
resumeAfterOAuth = vi.fn().mockResolvedValue(undefined);
checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(true);
}
const instances: FakeInspectorClient[] = [];
return {
@ -257,6 +259,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
getPromptState?: { status?: string };
readResourceState?: { status?: string };
currentLogLevel?: string;
activeTab?: string;
onActiveTabChange: (tab: string) => void;
onToggleConnection: (id: string) => void;
onToolsUiChange: (next: {
selectedToolName?: string;
@ -314,6 +318,10 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
{props.readResourceState?.status ?? "none"}
</span>
<span data-testid="log-level">{props.currentLogLevel}</span>
<span data-testid="active-tab">{props.activeTab ?? "none"}</span>
<button onClick={() => props.onActiveTabChange("Servers")}>
switch-servers-tab
</button>
<button onClick={() => props.onToggleConnection("A")}>connect</button>
<button
onClick={() =>
@ -404,6 +412,13 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
}));
import App from "./App";
import { OAUTH_CALLBACK_PATH } from "./utils/oauthFlow.js";
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs.js";
import {
readOAuthResumeSnapshot,
writeOAuthResumeSnapshot,
type OAuthResumeSnapshot,
} from "./utils/oauthResume.js";
import * as McpIndex from "@inspector/core/mcp/index.js";
import * as FetchLogModule from "@inspector/core/mcp/state/fetchRequestLogState.js";
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
@ -1589,3 +1604,153 @@ describe("App history pin/replay", () => {
);
});
});
describe("App OAuth resume lifecycle", () => {
const storage = new Map<string, string>();
const writeTestOAuthSnapshot = (
overrides?: Partial<OAuthResumeSnapshot>,
): void => {
writeOAuthResumeSnapshot({
version: 1,
serverId: "A",
activeTab: "Tools",
authKind: "reauth",
tabUi: {},
...overrides,
});
};
beforeEach(() => {
clientInstances.length = 0;
storage.clear();
notificationsMock.show.mockClear();
vi.stubGlobal("sessionStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
});
window.history.replaceState({}, "", "/");
});
afterEach(() => {
vi.unstubAllGlobals();
window.history.replaceState({}, "", "/");
});
it("preserves the OAuth resume snapshot when the transport disconnects", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
writeTestOAuthSnapshot();
await user.click(screen.getByText("select-tool"));
await waitFor(() =>
expect(screen.getByTestId("selected-tool")).toHaveTextContent("get_acts"),
);
act(() => {
clientInstances[0].dispatchEvent(new Event("disconnect"));
});
await waitFor(() =>
expect(screen.getByTestId("selected-tool")).toHaveTextContent("none"),
);
expect(readOAuthResumeSnapshot()?.serverId).toBe("A");
});
it("preserves the OAuth resume snapshot when reconnect rebuilds the client", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
writeTestOAuthSnapshot();
act(() => {
clientInstances[0].dispatchEvent(new Event("disconnect"));
});
expect(readOAuthResumeSnapshot()?.serverId).toBe("A");
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(2));
expect(readOAuthResumeSnapshot()?.serverId).toBe("A");
});
it("clears the OAuth resume snapshot on explicit disconnect toggle", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
writeTestOAuthSnapshot();
expect(readOAuthResumeSnapshot()?.serverId).toBe("A");
await user.click(screen.getByText("connect"));
await waitFor(() => expect(readOAuthResumeSnapshot()).toBeUndefined());
});
it("matches the OAuth callback to the pending server when a resume snapshot exists", async () => {
writeTestOAuthSnapshot();
window.history.replaceState({}, "", `${OAUTH_CALLBACK_PATH}?code=test`);
renderWithMantine(<App />);
await waitFor(() => expect(clientInstances).toHaveLength(1));
const client = clientInstances[0] as unknown as {
resumeAfterOAuth: ReturnType<typeof vi.fn>;
};
await waitFor(() =>
expect(client.resumeAfterOAuth).toHaveBeenCalledWith(
"test",
expect.any(Object),
),
);
expect(readOAuthResumeSnapshot()).toBeUndefined();
expect(
notificationsMock.show.mock.calls.some(
([args]) => args.title === "OAuth callback could not be matched",
),
).toBe(false);
});
it("does not restore a stale tab after callback consume and reconnect", async () => {
writeTestOAuthSnapshot({ activeTab: "Tools" });
window.history.replaceState({}, "", `${OAUTH_CALLBACK_PATH}?code=test`);
renderWithMantine(<App />);
await waitFor(() =>
expect(screen.getByTestId("active-tab")).toHaveTextContent("Tools"),
);
expect(readOAuthResumeSnapshot()).toBeUndefined();
window.history.replaceState({}, "", "/");
const user = userEvent.setup();
// Explicit disconnect while still on Tools (InspectorView clamps to Servers
// visually, but App must reset activeTab so reconnect does not pop back).
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(screen.getByTestId("active-tab")).toHaveTextContent(
INSPECTOR_SERVERS_TAB,
),
);
expect(readOAuthResumeSnapshot()).toBeUndefined();
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(screen.getByTestId("active-tab")).toHaveTextContent(
INSPECTOR_SERVERS_TAB,
),
);
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
import { describe, it, expect, vi } from "vitest";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import userEvent from "@testing-library/user-event";
import { ReAuthBanner } from "./ReAuthBanner";
describe("ReAuthBanner", () => {
it("renders message and action buttons", async () => {
const user = userEvent.setup();
const onReauthenticate = vi.fn();
const onDismiss = vi.fn();
renderWithMantine(
<ReAuthBanner
message="Sign in again to continue."
onReauthenticate={onReauthenticate}
onDismiss={onDismiss}
/>,
);
expect(screen.getByText("Re-authentication required")).toBeInTheDocument();
expect(screen.getByText("Sign in again to continue.")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Re-authenticate" }));
expect(onReauthenticate).toHaveBeenCalledOnce();
const [, closeButton] = screen.getAllByRole("button");
await user.click(closeButton);
expect(onDismiss).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,36 @@
import { Alert, Button, Group } from "@mantine/core";
export interface ReAuthBannerProps {
message: string;
onReauthenticate: () => void;
onDismiss: () => void;
}
export function ReAuthBanner({
message,
onReauthenticate,
onDismiss,
}: ReAuthBannerProps) {
return (
<Alert
color="red"
variant="light"
title="Re-authentication required"
withCloseButton
onClose={onDismiss}
styles={{
root: {
backgroundColor: "var(--mantine-color-body)",
border: "1px solid var(--mantine-color-red-3)",
},
}}
>
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
<span>{message}</span>
<Button size="xs" variant="filled" onClick={onReauthenticate}>
Re-authenticate
</Button>
</Group>
</Alert>
);
}

View file

@ -0,0 +1,120 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { StepUpAuthModal } from "./StepUpAuthModal";
const stepUpChallenge: AuthChallenge = {
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
authorizationScopes: ["mcp", "tools:read", "weather:read"],
context: { toolName: "get_temp" },
};
describe("StepUpAuthModal", () => {
it("does not render when closed", () => {
renderWithMantine(
<StepUpAuthModal
opened={false}
challenge={stepUpChallenge}
onAuthorize={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(
screen.queryByText(/Additional permissions required/i),
).not.toBeInTheDocument();
});
it("lists additional scopes from the challenge, not the SEP-2350 union", () => {
renderWithMantine(
<StepUpAuthModal
opened
challenge={stepUpChallenge}
authorizationScopes={stepUpChallenge.authorizationScopes}
onAuthorize={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(
screen.getByText(/Additional permissions required/i),
).toBeInTheDocument();
expect(screen.getByText(/get_temp/)).toBeInTheDocument();
expect(screen.getByText("weather:read")).toBeInTheDocument();
expect(screen.queryByText("tools:read")).not.toBeInTheDocument();
expect(screen.queryByText("mcp")).not.toBeInTheDocument();
expect(
screen.getByText(
/redirected to authorize, then returned to the inspector/i,
),
).toBeInTheDocument();
});
it("uses EMA copy when enterpriseManaged is true", () => {
renderWithMantine(
<StepUpAuthModal
opened
enterpriseManaged
challenge={stepUpChallenge}
onAuthorize={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(
screen.getByText(/Additional organization permissions required/i),
).toBeInTheDocument();
expect(
screen.getByText(/enterprise identity provider/i),
).toBeInTheDocument();
expect(
screen.queryByText(
/redirected to authorize, then returned to the inspector/i,
),
).not.toBeInTheDocument();
});
it("falls back to requiredScopes when authorizationScopes is empty", () => {
renderWithMantine(
<StepUpAuthModal
opened
challenge={{
reason: "insufficient_scope",
requiredScopes: ["admin:write"],
}}
onAuthorize={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByText("admin:write")).toBeInTheDocument();
});
it("calls onAuthorize when Authorize is clicked", async () => {
const user = userEvent.setup();
const onAuthorize = vi.fn();
renderWithMantine(
<StepUpAuthModal
opened
challenge={stepUpChallenge}
onAuthorize={onAuthorize}
onCancel={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: /^Authorize$/ }));
expect(onAuthorize).toHaveBeenCalledOnce();
});
it("calls onCancel when Cancel is clicked", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
renderWithMantine(
<StepUpAuthModal
opened
challenge={stepUpChallenge}
onAuthorize={vi.fn()}
onCancel={onCancel}
/>,
);
await user.click(screen.getByRole("button", { name: /^Cancel$/ }));
expect(onCancel).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,87 @@
import { Button, Group, List, Modal, Stack, Text } from "@mantine/core";
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import {
stepUpAdditionalScopes,
stepUpConfirmMessage,
stepUpFollowUpMessage,
stepUpModalTitle,
} from "@inspector/core/auth/oauthUx.js";
export interface StepUpAuthModalProps {
opened: boolean;
challenge: AuthChallenge | null;
/** Effective consent scope set (SEP-2350 union). */
authorizationScopes?: string[];
/** Enterprise-managed (EMA) server — organization/IdP copy instead of resource AS. */
enterpriseManaged?: boolean;
onAuthorize: () => void | Promise<void>;
onCancel: () => void;
}
const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" });
export function StepUpAuthModal({
opened,
challenge,
authorizationScopes,
enterpriseManaged,
onAuthorize,
onCancel,
}: StepUpAuthModalProps) {
const additionalScopes = challenge ? stepUpAdditionalScopes(challenge) : [];
const ema = enterpriseManaged === true;
return (
<Modal
opened={opened}
onClose={onCancel}
size="md"
centered
title={stepUpModalTitle({ enterpriseManaged: ema })}
>
<Stack gap="md">
<Text size="sm">
{challenge
? stepUpConfirmMessage(challenge, { enterpriseManaged: ema })
: stepUpConfirmMessage(
{ reason: "insufficient_scope" },
{ enterpriseManaged: ema },
)}{" "}
{stepUpFollowUpMessage({ enterpriseManaged: ema })}
</Text>
{additionalScopes.length > 0 ? (
<Stack gap="xs">
<Text size="sm" fw={600}>
{ema
? "Additional permissions needed"
: "Additional scopes needed"}
</Text>
<List size="sm" spacing="xs">
{additionalScopes.map((scope) => (
<List.Item key={scope}>
<Text component="span" ff="monospace" size="sm">
{scope}
</Text>
</List.Item>
))}
</List>
{!ema &&
authorizationScopes &&
authorizationScopes.length > additionalScopes.length ? (
<Text size="xs" c="dimmed">
The authorization server may also show scopes you already
granted during sign-in.
</Text>
) : null}
</Stack>
) : null}
<Actions>
<Button variant="default" onClick={onCancel}>
Cancel
</Button>
<Button onClick={() => void onAuthorize()}>Authorize</Button>
</Actions>
</Stack>
</Modal>
);
}

View file

@ -62,3 +62,15 @@ export const EMPTY_NETWORK_UI: NetworkUiState = {
filterText: "",
visibleCategories: ALL_CATEGORIES_VISIBLE,
};
/** Registry for OAuth resume snapshot restore (data-only; setters live in App). */
export const TAB_UI_REGISTRY = {
Apps: { empty: EMPTY_APPS_UI },
Tools: { empty: EMPTY_TOOLS_UI },
Prompts: { empty: EMPTY_PROMPTS_UI },
Resources: { empty: EMPTY_RESOURCES_UI },
Tasks: { empty: EMPTY_TASKS_UI },
Logs: { empty: EMPTY_LOGS_UI },
History: { empty: EMPTY_HISTORY_UI },
Network: { empty: EMPTY_NETWORK_UI },
} as const;

View file

@ -14,6 +14,7 @@ import type {
ServerEntry,
} from "@inspector/core/mcp/types.js";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { expect, fn } from "storybook/test";
import { InspectorView } from "./InspectorView";
import {
@ -400,6 +401,18 @@ const meta: Meta<typeof InspectorView> = {
onCloseApp: fn(),
onAppError: fn(),
onRefreshApps: fn(),
activeTab: "Servers",
onActiveTabChange: fn(),
},
render: (args) => {
const [activeTab, setActiveTab] = useState(args.activeTab ?? "Servers");
return (
<InspectorView
{...args}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
/>
);
},
};

View file

@ -18,7 +18,6 @@ import {
} from "../../../test/renderWithMantine";
import { InspectorView, type InspectorViewProps } from "./InspectorView";
import type { BridgeFactory } from "../../elements/AppRenderer/AppRenderer";
import type { AppsUiState } from "../../screens/AppsScreen/AppsScreen";
import {
EMPTY_TOOLS_UI,
EMPTY_APPS_UI,
@ -130,21 +129,23 @@ function makeProps(
onCloseApp: vi.fn(),
onAppError: vi.fn(),
onRefreshApps: vi.fn(),
activeTab: "Servers",
onActiveTabChange: vi.fn(),
...overrides,
};
}
// Most tests render the view fully prop-driven (every callback is a spy). A few
// interactions — selecting an App and watching it auto-launch — depend on the
// parent-owned selection state actually updating, since the view is controlled
// (#1417). This host holds the App-tab selection/form state and threads it back
// in as controlled props, mirroring how App.tsx owns it in the real wiring.
function StatefulInspectorView({ props }: { props: InspectorViewProps }) {
const [appsUi, setAppsUi] = useState<AppsUiState>(
props.appsUi ?? EMPTY_APPS_UI,
);
function StatefulInspectorViewHost(props: InspectorViewProps) {
const [activeTab, setActiveTab] = useState(props.activeTab ?? "Servers");
const [appsUi, setAppsUi] = useState(props.appsUi ?? EMPTY_APPS_UI);
return (
<InspectorView {...props} appsUi={appsUi} onAppsUiChange={setAppsUi} />
<InspectorView
{...props}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
appsUi={appsUi}
onAppsUiChange={setAppsUi}
/>
);
}
@ -210,7 +211,7 @@ const sampleTask: Task = {
describe("InspectorView", () => {
it("renders the empty-server-list placeholder when no servers are configured", () => {
renderWithMantine(<InspectorView {...makeProps()} />);
renderWithMantine(<StatefulInspectorViewHost {...makeProps()} />);
expect(
screen.getByText("No servers configured. Add a server to get started."),
).toBeInTheDocument();
@ -218,7 +219,7 @@ describe("InspectorView", () => {
it("renders the server card from the input list", () => {
renderWithMantine(
<InspectorView {...makeProps({ servers: [sampleServer] })} />,
<StatefulInspectorViewHost {...makeProps({ servers: [sampleServer] })} />,
);
expect(screen.getByText("Alpha")).toBeInTheDocument();
});
@ -227,7 +228,7 @@ describe("InspectorView", () => {
const onToggleConnection = vi.fn();
const user = userEvent.setup();
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({ servers: [sampleServer], onToggleConnection })}
/>,
);
@ -237,7 +238,7 @@ describe("InspectorView", () => {
it("renders the connected header when connectionStatus + initializeResult are set", () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -256,7 +257,7 @@ describe("InspectorView", () => {
it("surfaces the negotiated protocol version on the active connected card", () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -272,7 +273,7 @@ describe("InspectorView", () => {
it("does not show a protocol version on the card while disconnected", () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -291,7 +292,7 @@ describe("InspectorView", () => {
// version is somehow absent — the connected header/modal must still render
// (gated on serverInfo, not the version), and the card label stays hidden.
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -311,7 +312,7 @@ describe("InspectorView", () => {
it("snaps activeTab back to Servers when connection drops", async () => {
const { rerender } = renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -330,7 +331,7 @@ describe("InspectorView", () => {
);
rerender(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: undefined,
@ -347,7 +348,7 @@ describe("InspectorView", () => {
});
it("disables non-Servers tabs while disconnected", () => {
renderWithMantine(<InspectorView {...makeProps()} />);
renderWithMantine(<StatefulInspectorViewHost {...makeProps()} />);
// The disconnected ViewHeader doesn't render the tab Select at all —
// only the connected branch does. Asserting on the empty-state copy is
// enough; a follow-up could deepen this once the disconnected header
@ -359,7 +360,7 @@ describe("InspectorView", () => {
it("hides the Network tab when the active server is stdio", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -389,7 +390,7 @@ describe("InspectorView", () => {
serverInfo: { name: "Beta", version: "1.0.0" },
};
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [httpServer],
activeServer: "beta",
@ -405,7 +406,7 @@ describe("InspectorView", () => {
it("hides the Tools tab when the server does not advertise the tools capability", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -428,7 +429,7 @@ describe("InspectorView", () => {
it("shows the Tools tab when the server advertises tools even with an empty list", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -444,7 +445,7 @@ describe("InspectorView", () => {
it("hides the Logs tab when the server does not advertise the logging capability", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -461,7 +462,7 @@ describe("InspectorView", () => {
it("shows the Logs tab when the server advertises the logging capability", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -477,7 +478,7 @@ describe("InspectorView", () => {
it("keeps History available regardless of advertised server capabilities", async () => {
// History is a local client-side log — never gated on server capabilities.
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -497,7 +498,7 @@ describe("InspectorView", () => {
it("hides the Apps tab when app tools exist but the server omits the tools capability", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -530,8 +531,8 @@ describe("InspectorView", () => {
_meta: { ui: { resourceUri: "not-a-ui-uri" } },
};
renderWithMantine(
<StatefulInspectorView
props={makeProps({
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
@ -556,7 +557,7 @@ describe("InspectorView", () => {
inputSchema: { type: "object" },
};
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -575,7 +576,7 @@ describe("InspectorView", () => {
it("shows the Apps tab when the server exposes one or more MCP App tools", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -597,7 +598,7 @@ describe("InspectorView", () => {
inputSchema: { type: "object" },
};
const { rerender } = renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -613,7 +614,7 @@ describe("InspectorView", () => {
// A tools/list_changed refresh adds an app tool — the tab appears reactively.
rerender(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -632,7 +633,7 @@ describe("InspectorView", () => {
it("snaps activeTab back to Servers when the Apps tab disappears after a refresh", async () => {
const user = userEvent.setup();
const { rerender } = renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -652,7 +653,7 @@ describe("InspectorView", () => {
// The app tool goes away (server switch / list-changed) — the Apps tab is
// pulled from availableTabs and the activeTab fallback lands on Servers.
rerender(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -670,7 +671,7 @@ describe("InspectorView", () => {
it("hides the Prompts tab when the server does not advertise the prompts capability", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -691,7 +692,7 @@ describe("InspectorView", () => {
it("shows the Prompts tab when the server advertises prompts even with an empty list", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -709,7 +710,7 @@ describe("InspectorView", () => {
it("hides the Resources tab when the server does not advertise the resources capability", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -729,7 +730,7 @@ describe("InspectorView", () => {
it("shows the Resources tab when the server advertises resources even with empty lists", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -746,7 +747,7 @@ describe("InspectorView", () => {
it("hides the Tasks tab when the server does not advertise the tasks capability", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -765,7 +766,7 @@ describe("InspectorView", () => {
it("shows the Tasks tab when the server advertises tasks even with no tasks yet", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -782,7 +783,7 @@ describe("InspectorView", () => {
it("recomputes tabs from the new capability set when reconnecting to a different server", async () => {
// First server advertises tasks but not logging.
const { rerender } = renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -799,7 +800,7 @@ describe("InspectorView", () => {
// Reconnect to a server that advertises logging but not tasks — the tabs
// recompute purely from the new capability set.
rerender(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -820,7 +821,7 @@ describe("InspectorView", () => {
const onSetLogLevel = vi.fn();
const user = userEvent.setup();
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -851,7 +852,7 @@ describe("InspectorView", () => {
it("persists Logs sort direction to localStorage and restores it on remount", async () => {
const user = userEvent.setup();
const { unmount } = renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -880,7 +881,7 @@ describe("InspectorView", () => {
unmount();
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -903,7 +904,7 @@ describe("InspectorView", () => {
const user = userEvent.setup();
window.localStorage.setItem("inspector.sortDirection.history", "garbage");
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -935,7 +936,7 @@ describe("InspectorView", () => {
},
};
const { unmount } = renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -960,7 +961,7 @@ describe("InspectorView", () => {
unmount();
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -994,7 +995,7 @@ describe("InspectorView", () => {
},
};
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -1021,7 +1022,7 @@ describe("InspectorView", () => {
connection: { status: "disconnected" },
};
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer, betaServer],
activeServer: "alpha",
@ -1045,7 +1046,7 @@ describe("InspectorView", () => {
};
// Live session on alpha → beta starts out dimmed/inert.
const { rerender } = renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer, betaServer],
activeServer: "alpha",
@ -1066,7 +1067,7 @@ describe("InspectorView", () => {
// id still points at alpha. The other cards must re-enable anyway; only a
// *live* session should dim them.
rerender(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer, betaServer],
activeServer: "alpha",
@ -1086,7 +1087,7 @@ describe("InspectorView", () => {
it("toggles the Servers list compact state from the list toggle", async () => {
const user = userEvent.setup();
renderWithMantine(
<InspectorView {...makeProps({ servers: [sampleServer] })} />,
<StatefulInspectorViewHost {...makeProps({ servers: [sampleServer] })} />,
);
// Servers default to expanded (compact=false), so the toggle reads
// "Collapse all"; clicking it flips serversCompact via the inline callback.
@ -1106,7 +1107,7 @@ describe("InspectorView", () => {
connection: { status: "connected" },
};
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [httpServer],
activeServer: "beta",
@ -1150,7 +1151,7 @@ describe("InspectorView", () => {
connection: { status: "connected" },
};
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [httpServer],
activeServer: "ghost",
@ -1176,7 +1177,7 @@ describe("InspectorView", () => {
it("routes toolsListChanged to the Tools screen indicator", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -1192,7 +1193,7 @@ describe("InspectorView", () => {
it("shares the tools flag with the Apps screen (apps are filtered tools)", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -1211,7 +1212,7 @@ describe("InspectorView", () => {
it("routes promptsListChanged to the Prompts screen indicator", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -1230,7 +1231,7 @@ describe("InspectorView", () => {
it("routes resourcesListChanged to the Resources screen indicator", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
@ -1249,7 +1250,7 @@ describe("InspectorView", () => {
it("does not show the indicator on a screen whose flag is false (no cross-wiring)", async () => {
renderWithMantine(
<InspectorView
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",

View file

@ -1,4 +1,4 @@
import { useMemo, useState, type ReactNode, type Ref } from "react";
import { useMemo, type ReactNode, type Ref } from "react";
import { AppShell, Box, Stack, Transition } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import type {
@ -65,6 +65,7 @@ import {
} from "../../screens/NetworkScreen/NetworkScreen";
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
import { getServerType } from "@inspector/core/mcp/config.js";
import { INSPECTOR_SERVERS_TAB } from "../../../utils/inspectorTabs";
const SORT_DEFAULT: SortDirection = "newest-first";
@ -125,7 +126,7 @@ function useListCompact(
});
}
const SERVERS_TAB = "Servers";
const SERVERS_TAB = INSPECTOR_SERVERS_TAB;
const NETWORK_TAB = "Network";
const ALL_TABS: string[] = [
@ -241,6 +242,10 @@ export interface InspectorViewProps {
historyUi: HistoryUiState;
networkUi: NetworkUiState;
/** Active inspector tab (lifted to App for OAuth resume). */
activeTab: string;
onActiveTabChange: (tab: string) => void;
// Logging level. The MCP `logging/setLevel` request has no echo
// notification, so the parent keeps the optimistic current value.
currentLogLevel: LoggingLevel;
@ -455,11 +460,12 @@ export function InspectorView({
onCloseApp,
onAppError,
onRefreshApps,
activeTab: activeTabProp,
onActiveTabChange,
}: InspectorViewProps) {
// UI-only state. Connection state, primitive lists, and all action
// dispatching live in the parent; this component only owns navigation
// (which tab is visible) and a couple of view-local toggles.
const [selectedTab, setSelectedTab] = useState<string>(SERVERS_TAB);
// dispatching live in the parent; this component only owns view-local
// toggles (sort direction, list compact). Tab selection is lifted (#1417).
const [logsSort, setLogsSort] = useSortDirection("logs");
const [historySort, setHistorySort] = useSortDirection("history");
@ -557,9 +563,9 @@ export function InspectorView({
// `[Servers]` and the view renders Servers without us having to imperatively
// reset the state (and trip the `set-state-in-effect` lint). When the
// connection comes back, the previous selection pops in again because
// `selectedTab` is preserved.
const activeTab = availableTabs.includes(selectedTab)
? selectedTab
// the parent's `activeTab` is preserved.
const activeTab = availableTabs.includes(activeTabProp)
? activeTabProp
: SERVERS_TAB;
// Merge the parent's `serversInput` (static config) with the runtime
@ -627,7 +633,7 @@ export function InspectorView({
latencyMs={latencyMs}
activeTab={activeTab}
availableTabs={availableTabs}
onTabChange={setSelectedTab}
onTabChange={onActiveTabChange}
onDisconnect={onDisconnect}
onToggleTheme={onToggleTheme}
onOpenClientSettings={onOpenClientSettings}

View file

@ -0,0 +1,237 @@
import { describe, it, expect } from "vitest";
import {
AuthChallengeError,
AuthRecoveryRequiredError,
isAuthChallengeError,
isConnectAuthRecoveryError,
parseAuthChallengeFromError,
parseAuthChallengeFromResponse,
parseScopeString,
parseWwwAuthenticateBearer,
unionAuthorizationScopes,
} from "@inspector/core/auth/challenge.js";
describe("parseWwwAuthenticateBearer", () => {
it("parses insufficient_scope and scope parameters", () => {
expect(
parseWwwAuthenticateBearer(
'Bearer error="insufficient_scope", scope="weather:read admin:write"',
),
).toEqual({
error: "insufficient_scope",
scope: "weather:read admin:write",
resourceMetadata: undefined,
errorDescription: undefined,
});
});
it("parses invalid_token and error_description", () => {
expect(
parseWwwAuthenticateBearer(
'Bearer error="invalid_token", error_description="Token expired"',
),
).toEqual({
error: "invalid_token",
scope: undefined,
resourceMetadata: undefined,
errorDescription: "Token expired",
});
});
it("parses unquoted RFC 6750 parameters", () => {
expect(
parseWwwAuthenticateBearer(
"Bearer error=insufficient_scope, scope=weather:read",
),
).toEqual({
error: "insufficient_scope",
scope: "weather:read",
resourceMetadata: undefined,
errorDescription: undefined,
});
});
it("returns empty object for non-Bearer challenges", () => {
expect(parseWwwAuthenticateBearer('Basic realm="test"')).toEqual({});
});
});
describe("parseScopeString", () => {
it("splits space-separated scopes", () => {
expect(parseScopeString("mcp tools:read")).toEqual(["mcp", "tools:read"]);
});
it("returns empty array for blank input", () => {
expect(parseScopeString(undefined)).toEqual([]);
expect(parseScopeString(" ")).toEqual([]);
});
});
describe("unionAuthorizationScopes", () => {
it("unions previous and required scopes without duplicates", () => {
expect(
unionAuthorizationScopes("mcp tools:read", [
"tools:read",
"weather:read",
]),
).toEqual(["mcp", "tools:read", "weather:read"]);
});
it("returns required scopes when no previous scope exists", () => {
expect(unionAuthorizationScopes(undefined, ["weather:read"])).toEqual([
"weather:read",
]);
});
});
describe("parseAuthChallengeFromResponse", () => {
it("maps 401 invalid_token to invalid_token reason", () => {
const response = new Response(null, {
status: 401,
headers: {
"WWW-Authenticate": 'Bearer error="invalid_token"',
},
});
expect(parseAuthChallengeFromResponse(response)).toEqual({
reason: "invalid_token",
raw: {
httpStatus: 401,
wwwAuthenticate: 'Bearer error="invalid_token"',
},
});
});
it("maps 401 without error to token_expired", () => {
const response = new Response(null, { status: 401 });
expect(parseAuthChallengeFromResponse(response)?.reason).toBe(
"token_expired",
);
});
it("maps 401 insufficient_scope to insufficient_scope", () => {
const response = new Response(null, {
status: 401,
headers: {
"WWW-Authenticate":
'Bearer error="insufficient_scope", scope="weather:read"',
},
});
expect(parseAuthChallengeFromResponse(response)).toMatchObject({
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
});
});
it("maps 403 insufficient_scope with required scopes", () => {
const response = new Response(null, {
status: 403,
headers: {
"WWW-Authenticate":
'Bearer error="insufficient_scope", scope="weather:read"',
},
});
expect(
parseAuthChallengeFromResponse(response, { toolName: "get_temp" }),
).toMatchObject({
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
context: { toolName: "get_temp" },
});
});
it("returns undefined for non-auth statuses", () => {
const response = new Response(null, { status: 500 });
expect(parseAuthChallengeFromResponse(response)).toBeUndefined();
});
});
describe("parseAuthChallengeFromError", () => {
it("extracts embedded authChallenge objects", () => {
const challenge = {
reason: "token_expired" as const,
};
expect(parseAuthChallengeFromError({ authChallenge: challenge })).toEqual(
challenge,
);
});
it("builds a challenge from status and WWW-Authenticate on errors", () => {
expect(
parseAuthChallengeFromError({
status: 403,
wwwAuthenticate:
'Bearer error="insufficient_scope", scope="admin:write"',
}),
).toMatchObject({
reason: "insufficient_scope",
requiredScopes: ["admin:write"],
});
});
it("returns undefined for bare 401 without auth markers", () => {
expect(parseAuthChallengeFromError({ status: 401 })).toBeUndefined();
});
});
describe("isAuthChallengeError", () => {
it("detects AuthChallengeError instances", () => {
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
expect(isAuthChallengeError(err)).toBe(true);
});
it("detects 401 and 403 with WWW-Authenticate as auth challenges", () => {
expect(
isAuthChallengeError({
status: 401,
wwwAuthenticate: 'Bearer error="invalid_token"',
}),
).toBe(true);
expect(
isAuthChallengeError({
status: 403,
wwwAuthenticate: 'Bearer error="insufficient_scope"',
}),
).toBe(true);
expect(isAuthChallengeError({ status: 500 })).toBe(false);
});
it("does not treat bare 401/403 status without auth markers as auth challenge", () => {
expect(isAuthChallengeError({ status: 401 })).toBe(false);
expect(isAuthChallengeError({ status: 403 })).toBe(false);
});
it("does not treat connect-time unauthorized wording as auth challenge", () => {
expect(isAuthChallengeError(new Error("network failed"))).toBe(false);
});
});
describe("isConnectAuthRecoveryError", () => {
it("treats AuthRecoveryRequiredError and 401 connect failures as recoverable", () => {
expect(
isConnectAuthRecoveryError(
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
reason: "unauthorized",
}),
),
).toBe(true);
const unauthorized = new Error("Unauthorized") as Error & {
status?: number;
};
unauthorized.status = 401;
expect(isConnectAuthRecoveryError(unauthorized)).toBe(true);
});
it("does not treat other handshake failures as recoverable", () => {
expect(isConnectAuthRecoveryError(new Error("Connection timed out"))).toBe(
false,
);
expect(
isConnectAuthRecoveryError(
new AuthChallengeError({ reason: "token_expired" }, 403),
),
).toBe(false);
});
});

View file

@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
const {
sdkAuth,
discoverOAuthServerInfo,
startAuthorization,
selectResourceURL,
} = vi.hoisted(() => ({
sdkAuth: vi.fn(),
discoverOAuthServerInfo: vi.fn(),
startAuthorization: vi.fn(),
selectResourceURL: vi.fn(),
}));
vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@modelcontextprotocol/sdk/client/auth.js")
>();
return {
...actual,
auth: sdkAuth,
discoverOAuthServerInfo,
selectResourceURL,
startAuthorization,
};
});
import { mcpAuth } from "@inspector/core/auth/mcpAuth.js";
function makeProvider(): OAuthClientProvider {
return {
redirectUrl: "http://localhost/callback",
clientMetadata: {
redirect_uris: ["http://localhost/callback"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "test",
client_uri: "https://example.com",
scope: "",
},
clientInformation: vi.fn().mockResolvedValue({ client_id: "cid" }),
tokens: vi.fn(),
saveTokens: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn().mockReturnValue("verifier"),
};
}
describe("mcpAuth", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("delegates to SDK auth() when forceReauthorization is not set", async () => {
sdkAuth.mockResolvedValue("AUTHORIZED");
const provider = makeProvider();
const result = await mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
authorizationCode: "code",
fetchFn: fetch,
});
expect(result).toBe("AUTHORIZED");
expect(sdkAuth).toHaveBeenCalledWith(provider, {
serverUrl: "https://mcp.example.com",
authorizationCode: "code",
scope: undefined,
resourceMetadataUrl: undefined,
fetchFn: fetch,
});
expect(discoverOAuthServerInfo).not.toHaveBeenCalled();
});
it("uses discovery + startAuthorization when forceReauthorization is true", async () => {
discoverOAuthServerInfo.mockResolvedValue({
authorizationServerUrl: "https://as.example.com",
authorizationServerMetadata: { issuer: "https://as.example.com" },
resourceMetadata: undefined,
});
selectResourceURL.mockResolvedValue(undefined);
startAuthorization.mockResolvedValue({
authorizationUrl: new URL("https://as.example.com/authorize"),
codeVerifier: "cv",
});
const provider = makeProvider();
const result = await mcpAuth(provider, {
serverUrl: "https://mcp.example.com",
scope: "mcp weather:read",
forceReauthorization: true,
fetchFn: fetch,
});
expect(result).toBe("REDIRECT");
expect(sdkAuth).not.toHaveBeenCalled();
expect(startAuthorization).toHaveBeenCalled();
expect(provider.saveCodeVerifier).toHaveBeenCalledWith("cv");
expect(provider.redirectToAuthorization).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,82 @@
import { describe, it, expect } from "vitest";
import {
emaStepUpSuccessMessage,
isEmaStepUp,
isStepUpConfirmation,
stepUpAdditionalScopes,
stepUpConfirmMessage,
stepUpFollowUpMessage,
stepUpModalTitle,
stepUpAuthorizeActionLabel,
} from "@inspector/core/auth/oauthUx.js";
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
describe("oauthUx step-up copy", () => {
const challenge: AuthChallenge = {
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
authorizationScopes: ["mcp", "tools:read", "weather:read"],
context: { toolName: "get_temp" },
};
it("stepUpConfirmMessage prefers tool context over scope union", () => {
expect(stepUpConfirmMessage(challenge)).toMatch(/get_temp/);
expect(stepUpConfirmMessage(challenge)).not.toMatch(/tools:read/);
});
it("stepUpConfirmMessage lists only requiredScopes when no tool context", () => {
expect(
stepUpConfirmMessage({
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
authorizationScopes: ["mcp", "tools:read", "weather:read"],
}),
).toBe("This operation needs additional scope: weather:read.");
});
it("stepUpConfirmMessage uses organization language for EMA", () => {
expect(
stepUpConfirmMessage(challenge, { enterpriseManaged: true }),
).toMatch(/organization/i);
expect(stepUpFollowUpMessage({ enterpriseManaged: true })).toMatch(
/identity provider/i,
);
expect(stepUpModalTitle({ enterpriseManaged: true })).toMatch(
/organization/i,
);
expect(stepUpAuthorizeActionLabel({ enterpriseManaged: true })).toBe(
"Authorize",
);
expect(stepUpAuthorizeActionLabel()).toBe("Authorize (opens browser)");
});
it("isStepUpConfirmation covers standard OAuth and EMA insufficient_scope", () => {
expect(isStepUpConfirmation(challenge)).toBe(true);
expect(isStepUpConfirmation(challenge, { enterpriseManaged: true })).toBe(
true,
);
expect(isEmaStepUp(challenge, { enterpriseManaged: true })).toBe(true);
expect(isEmaStepUp(challenge)).toBe(false);
expect(
isStepUpConfirmation(
{ reason: "token_expired" },
{
enterpriseManaged: true,
},
),
).toBe(false);
});
it("emaStepUpSuccessMessage suggests retry only for command-scoped recovery", () => {
expect(emaStepUpSuccessMessage()).toBe(
"Organization permissions were updated.",
);
expect(emaStepUpSuccessMessage({ recoverySource: "tool" })).toMatch(
/Retry your action/,
);
});
it("stepUpAdditionalScopes returns requiredScopes only", () => {
expect(stepUpAdditionalScopes(challenge)).toEqual(["weather:read"]);
});
});

View file

@ -327,6 +327,23 @@ describe("OAuthNavigation", () => {
expect(navCallback).toHaveBeenCalledWith(url);
});
it("redirectToAuthorization captures URL but skips navigation when suppressed", () => {
const storage = makeStorage();
const navCallback = vi.fn();
const provider = makeProvider(storage, navCallback);
const url = new URL("https://mcp.example.com/authorize");
provider.setSuppressAuthorizationNavigation(true);
provider.redirectToAuthorization(url);
expect(provider.getCapturedAuthUrl()).toBe(url);
expect(navCallback).not.toHaveBeenCalled();
provider.setSuppressAuthorizationNavigation(false);
provider.redirectToAuthorization(url);
expect(navCallback).toHaveBeenCalledWith(url);
});
it("delegates token and scope persistence to storage", async () => {
const storage = makeStorage();
const provider = makeProvider(storage);

View file

@ -0,0 +1,283 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
runRunnerInteractiveOAuth,
type RunnerInteractiveOAuthClient,
} from "@inspector/core/auth/node/runner-interactive-oauth.js";
import type {
OAuthCallbackServer,
OAuthCallbackServerStartOptions,
} from "@inspector/core/auth/node/oauth-callback-server.js";
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
function mockClient(
overrides: Partial<RunnerInteractiveOAuthClient> = {},
): RunnerInteractiveOAuthClient {
return {
authenticate: vi.fn(async () => undefined),
beginInteractiveAuthorization: vi.fn(async () => {}),
completeOAuthFlow: vi.fn(async () => {}),
checkAuthChallengeSatisfied: vi.fn(async () => true),
...overrides,
};
}
interface MockCallbackHandlers {
onCallback?: OAuthCallbackServerStartOptions["onCallback"];
onError?: OAuthCallbackServerStartOptions["onError"];
}
function createMockCallbackServer(handlers: {
current: MockCallbackHandlers;
}): OAuthCallbackServer {
return {
start: vi.fn(async (opts: OAuthCallbackServerStartOptions) => {
handlers.current = {
onCallback: opts.onCallback,
onError: opts.onError,
};
return {
port: 6276,
redirectUrl: "http://127.0.0.1:6276/oauth/callback",
};
}),
stop: vi.fn(async () => {}),
} as unknown as OAuthCallbackServer;
}
/** Drive the loopback callback the way a browser redirect would. */
async function simulateCallback(
handlers: MockCallbackHandlers,
code = "auth-code-123",
): Promise<void> {
if (!handlers.onCallback) {
throw new Error("onCallback not registered");
}
await handlers.onCallback({ code });
}
describe("runRunnerInteractiveOAuth", () => {
const handlers: { current: MockCallbackHandlers } = { current: {} };
afterEach(() => {
handlers.current = {};
vi.restoreAllMocks();
});
it("returns already_authorized when authenticate yields no URL", async () => {
const client = mockClient({
authenticate: vi.fn(async () => undefined),
});
const redirectUrlProvider = { redirectUrl: "" };
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
createCallbackServer: () => createMockCallbackServer(handlers),
});
expect(result).toEqual({ kind: "already_authorized" });
expect(client.completeOAuthFlow).not.toHaveBeenCalled();
});
it("completes connect-time OAuth via authenticate and callback", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const client = mockClient({
authenticate: vi.fn(async () => {
await simulateCallback(handlers.current);
return new URL("https://as.example/authorize");
}),
});
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
createCallbackServer: () => createMockCallbackServer(handlers),
});
expect(result).toEqual({ kind: "success" });
expect(client.completeOAuthFlow).toHaveBeenCalledWith("auth-code-123");
expect(redirectUrlProvider.redirectUrl).toBe(
"http://127.0.0.1:6276/oauth/callback",
);
});
it("uses beginInteractiveAuthorization when authorizationUrl is provided", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const authorizationUrl = new URL(
"https://as.example/authorize?state=step-up",
);
const client = mockClient({
beginInteractiveAuthorization: vi.fn(async () => {
await simulateCallback(handlers.current, "step-up-code");
}),
});
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
authorizationUrl,
createCallbackServer: () => createMockCallbackServer(handlers),
});
expect(result).toEqual({ kind: "success" });
expect(client.beginInteractiveAuthorization).toHaveBeenCalledWith(
authorizationUrl,
);
expect(client.authenticate).not.toHaveBeenCalled();
expect(client.completeOAuthFlow).toHaveBeenCalledWith("step-up-code");
});
it("returns insufficient_scope when post-step-up check fails", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const challenge: AuthChallenge = {
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
};
const client = mockClient({
beginInteractiveAuthorization: vi.fn(async () => {
await simulateCallback(handlers.current);
}),
checkAuthChallengeSatisfied: vi.fn(async () => false),
});
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
authorizationUrl: new URL("https://as.example/authorize"),
authChallenge: challenge,
createCallbackServer: () => createMockCallbackServer(handlers),
});
expect(result).toEqual({ kind: "insufficient_scope", challenge });
});
it("propagates completeOAuthFlow failures", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const client = mockClient({
authenticate: vi.fn(async () => {
await simulateCallback(handlers.current);
return new URL("https://as.example/authorize");
}),
completeOAuthFlow: vi.fn(async () => {
throw new Error("token exchange failed");
}),
});
await expect(
runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
createCallbackServer: () => createMockCallbackServer(handlers),
}),
).rejects.toThrow("token exchange failed");
});
it("propagates OAuth callback errors from the authorization server", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const client = mockClient({
authenticate: vi.fn(async () => {
handlers.current.onError?.({
error: "access_denied",
error_description: "user cancelled",
});
return new URL("https://as.example/authorize");
}),
});
await expect(
runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
createCallbackServer: () => createMockCallbackServer(handlers),
}),
).rejects.toThrow("user cancelled");
});
it("times out when the browser callback never arrives", async () => {
vi.useFakeTimers();
try {
const redirectUrlProvider = { redirectUrl: "" };
const client = mockClient({
authenticate: vi.fn(
async () => new URL("https://as.example/authorize"),
),
});
const promise = runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
callbackTimeoutMs: 1000,
createCallbackServer: () => createMockCallbackServer(handlers),
});
const assertRejects =
expect(promise).rejects.toThrow(/timed out after 1s/);
await vi.advanceTimersByTimeAsync(1000);
await assertRejects;
} finally {
vi.useRealTimers();
}
});
it("invokes onCallbackServer with the live listener", async () => {
const redirectUrlProvider = { redirectUrl: "" };
const onCallbackServer = vi.fn();
const mockServer = createMockCallbackServer(handlers);
const client = mockClient({
authenticate: vi.fn(async () => {
await simulateCallback(handlers.current);
return new URL("https://as.example/authorize");
}),
});
await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
},
onCallbackServer,
createCallbackServer: () => mockServer,
});
expect(onCallbackServer).toHaveBeenCalledTimes(1);
expect(onCallbackServer).toHaveBeenCalledWith(mockServer);
});
});

View file

@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import {
computeScopeUnion,
isStrictScopeSuperset,
} from "@inspector/core/auth/scopes.js";
describe("scopes", () => {
describe("computeScopeUnion", () => {
it("unions and dedupes scope tokens", () => {
expect(
computeScopeUnion("mcp tools:read", "tools:read weather:read"),
).toBe("mcp tools:read weather:read");
});
it("returns undefined when all inputs are empty", () => {
expect(computeScopeUnion(undefined, "", undefined)).toBeUndefined();
});
});
describe("isStrictScopeSuperset", () => {
it("returns true when union adds a new scope", () => {
expect(
isStrictScopeSuperset("mcp tools:read weather:read", "mcp tools:read"),
).toBe(true);
});
it("returns false when union is covered by current grant", () => {
expect(isStrictScopeSuperset("mcp tools:read", "mcp tools:read")).toBe(
false,
);
});
it("treats missing token scope as empty (forces re-auth)", () => {
expect(isStrictScopeSuperset("mcp weather:read", undefined)).toBe(true);
});
});
});

View file

@ -5,7 +5,9 @@ import {
generateOAuthState,
parseOAuthState,
generateOAuthErrorDescription,
formatOAuthFailureDetail,
} from "@inspector/core/auth/utils.js";
import { ZodError } from "zod";
describe("parseHttpUrl", () => {
it("parses valid URLs", () => {
@ -93,3 +95,31 @@ describe("generateOAuthErrorDescription", () => {
);
});
});
describe("formatOAuthFailureDetail", () => {
const zodTokenJson = `[ { "expected": "string", "code": "invalid_type", "path": [ "access_token" ], "message": "Invalid input" }, { "expected": "string", "code": "invalid_type", "path": [ "token_type" ], "message": "Invalid input" } ]`;
it("replaces serialized Zod token-response issues with readable copy", () => {
expect(formatOAuthFailureDetail(zodTokenJson)).toBe(
"The authorization server did not return valid tokens. Check your OAuth client ID and secret, then try again.",
);
});
it("formats ZodError instances", () => {
const err = new ZodError([
{
code: "invalid_type",
expected: "string",
path: ["access_token"],
message: "Invalid input",
},
]);
expect(formatOAuthFailureDetail(err)).toMatch(/valid tokens/i);
});
it("passes through ordinary error messages", () => {
expect(formatOAuthFailureDetail(new Error("Network timeout"))).toBe(
"Network timeout",
);
});
});

View file

@ -0,0 +1,288 @@
/**
* Remote transport contract: HTTP ack + SSE payload, auth retry on send.
*/
import { describe, it, expect, vi } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js";
import { MessageTrackingTransport } from "@inspector/core/mcp/messageTrackingTransport.js";
import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js";
const config = {
type: "streamable-http" as const,
url: "http://localhost/mcp",
};
/** SSE stream that can push MCP message events after /api/mcp/send accepts a request. */
function createPushableSseStream() {
const encoder = new TextEncoder();
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
const stream = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
c.enqueue(encoder.encode(": keepalive\n\n"));
},
});
const pushMessage = (message: unknown) => {
const payload = JSON.stringify({ type: "message", data: message });
controller?.enqueue(encoder.encode(`data: ${payload}\n\n`));
};
return {
response: new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
pushMessage,
};
}
describe("RemoteClientTransport send contract", () => {
it("waits for SSE response after HTTP ok:true and completes within 1s", async () => {
const sse = createPushableSseStream();
const fetchFn = vi
.fn<typeof fetch>()
.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/mcp/connect") && init?.method === "POST") {
return new Response(JSON.stringify({ sessionId: "s1" }), {
status: 200,
});
}
if (url.includes("/api/mcp/events")) {
return sse.response;
}
if (url.endsWith("/api/mcp/send") && init?.method === "POST") {
const body = JSON.parse(String(init.body)) as {
message: { id?: string | number; method?: string };
};
sse.pushMessage({
jsonrpc: "2.0",
id: body.message.id,
result: { tools: [] },
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const transport = new RemoteClientTransport(
{
baseUrl: "http://remote.test/",
fetchFn: fetchFn as unknown as typeof fetch,
sseResponseTimeoutMs: 2000,
},
config,
);
await transport.start();
const started = Date.now();
await transport.send({ jsonrpc: "2.0", id: 7, method: "tools/list" });
expect(Date.now() - started).toBeLessThan(1000);
await transport.close();
});
it("times out within 5s when HTTP ok:true but no SSE response arrives", async () => {
const sse = createPushableSseStream();
const fetchFn = vi
.fn<typeof fetch>()
.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/mcp/connect") && init?.method === "POST") {
return new Response(JSON.stringify({ sessionId: "s1" }), {
status: 200,
});
}
if (url.includes("/api/mcp/events")) {
return sse.response;
}
if (url.endsWith("/api/mcp/send") && init?.method === "POST") {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const transport = new RemoteClientTransport(
{
baseUrl: "http://remote.test/",
fetchFn: fetchFn as unknown as typeof fetch,
sseResponseTimeoutMs: 500,
},
config,
);
await transport.start();
const started = Date.now();
await expect(
transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
).rejects.toThrow(/Timed out waiting for MCP response on SSE/i);
expect(Date.now() - started).toBeLessThan(5000);
await transport.close();
});
it("retries send once after satisfied auth challenge and pushAuthState", async () => {
const sse = createPushableSseStream();
let sendCalls = 0;
let connectCalls = 0;
const fetchFn = vi
.fn<typeof fetch>()
.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/mcp/connect") && init?.method === "POST") {
connectCalls += 1;
return new Response(
JSON.stringify({ sessionId: `s${connectCalls}` }),
{ status: 200 },
);
}
if (url.includes("/api/mcp/events")) {
return sse.response;
}
if (url.endsWith("/api/mcp/send") && init?.method === "POST") {
sendCalls += 1;
const body = JSON.parse(String(init.body)) as {
message: { id?: string | number };
};
if (sendCalls === 1) {
return new Response(
JSON.stringify({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "token_expired" },
}),
{ status: 200 },
);
}
sse.pushMessage({
jsonrpc: "2.0",
id: body.message.id,
result: { tools: [{ name: "echo" }] },
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const pushAuthState = vi.fn().mockResolvedValue(undefined);
const transport = new RemoteClientTransport(
{
baseUrl: "http://remote.test/",
fetchFn: fetchFn as unknown as typeof fetch,
sseResponseTimeoutMs: 2000,
authRecovery: {
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
pushAuthState,
},
},
config,
);
await transport.start();
const started = Date.now();
await transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" });
expect(Date.now() - started).toBeLessThan(5000);
expect(sendCalls).toBe(2);
expect(connectCalls).toBe(1);
expect(pushAuthState).toHaveBeenCalledTimes(1);
await transport.close();
});
it("SDK listTools succeeds through full stack after auth retry", async () => {
let sse = createPushableSseStream();
let sendCalls = 0;
const fetchFn = vi
.fn<typeof fetch>()
.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/mcp/connect") && init?.method === "POST") {
return new Response(JSON.stringify({ sessionId: "s1" }), {
status: 200,
});
}
if (url.includes("/api/mcp/events")) {
sse = createPushableSseStream();
return sse.response;
}
if (url.endsWith("/api/mcp/auth-state") && init?.method === "POST") {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (url.endsWith("/api/mcp/send") && init?.method === "POST") {
sendCalls += 1;
const body = JSON.parse(String(init.body)) as {
message: { method?: string; id?: string | number };
};
if (body.message.method === "initialize") {
sse.pushMessage({
jsonrpc: "2.0",
id: body.message.id,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: "test", version: "1.0.0" },
},
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (body.message.method === "tools/list") {
if (sendCalls === 2) {
return new Response(
JSON.stringify({
ok: false,
kind: "auth_challenge",
authChallenge: { reason: "token_expired" },
}),
{ status: 200 },
);
}
sse.pushMessage({
jsonrpc: "2.0",
id: body.message.id,
result: {
tools: [{ name: "echo", inputSchema: { type: "object" } }],
},
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const authProvider = {
tokens: vi.fn().mockResolvedValue({
access_token: "refreshed",
token_type: "Bearer",
}),
};
const transport = new RemoteClientTransport(
{
baseUrl: "http://remote.test/",
fetchFn: fetchFn as unknown as typeof fetch,
sseResponseTimeoutMs: 2000,
authProvider: authProvider as never,
},
config,
);
transport.setAuthRecovery({
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
pushAuthState: () => transport.pushAuthState(),
});
const wrapped = new MessageTrackingTransport(transport, {});
const client = new Client({ name: "test", version: "1.0.0" });
await client.connect(wrapped);
const started = Date.now();
const result = await client.listTools();
expect(Date.now() - started).toBeLessThan(5000);
expect(result.tools).toHaveLength(1);
await transport.close();
});
});

View file

@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js";
describe("InspectorClient ambient auth dedup", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("joins concurrent handleAmbientAuthChallenge calls for the same challenge", async () => {
const client = Object.create(InspectorClient.prototype) as InspectorClient;
const handleAuthChallenge = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ kind: "satisfied" as const }), 20);
}),
);
(client as unknown as { oauthManager: unknown }).oauthManager = {
handleAuthChallenge,
};
(
client as unknown as {
ambientAuthChallengeInFlight: Map<string, Promise<void>>;
}
).ambientAuthChallengeInFlight = new Map();
(client as unknown as { baseTransport: unknown }).baseTransport =
Object.create(RemoteClientTransport.prototype);
vi.spyOn(client, "pushRemoteAuthState").mockResolvedValue(undefined);
vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {});
const challenge = {
reason: "token_expired" as const,
};
await Promise.all([
client.handleAmbientAuthChallenge(challenge),
client.handleAmbientAuthChallenge(challenge),
]);
expect(handleAuthChallenge).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,52 @@
import { describe, it, expect, vi } from "vitest";
import { createAuthChallengeInterceptFetch } from "@inspector/core/mcp/node/authChallengeFetch.js";
import { AuthChallengeError } from "@inspector/core/auth/challenge.js";
describe("createAuthChallengeInterceptFetch", () => {
it("passes through successful responses", async () => {
const baseFetch = vi.fn(async () => new Response("ok", { status: 200 }));
const fetchFn = createAuthChallengeInterceptFetch(baseFetch);
const res = await fetchFn("https://example.com/mcp");
expect(res.status).toBe(200);
expect(await res.text()).toBe("ok");
});
it("throws AuthChallengeError on 401", async () => {
const baseFetch = vi.fn(
async () =>
new Response(null, {
status: 401,
headers: {
"WWW-Authenticate": 'Bearer error="invalid_token"',
},
}),
);
const fetchFn = createAuthChallengeInterceptFetch(baseFetch);
await expect(fetchFn("https://example.com/mcp")).rejects.toBeInstanceOf(
AuthChallengeError,
);
});
it("throws AuthChallengeError on 403 insufficient_scope", async () => {
const baseFetch = vi.fn(
async () =>
new Response(null, {
status: 403,
headers: {
"WWW-Authenticate":
'Bearer error="insufficient_scope", scope="weather:read"',
},
}),
);
const fetchFn = createAuthChallengeInterceptFetch(baseFetch);
try {
await fetchFn("https://example.com/mcp");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(AuthChallengeError);
expect((err as AuthChallengeError).authChallenge.reason).toBe(
"insufficient_scope",
);
}
});
});

Some files were not shown because too many files have changed in this diff Show more