Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1
# Conflicts: # AGENTS.md # clients/cli/README.md # clients/web/src/App.test.tsx # clients/web/src/App.tsx # clients/web/src/test/core/auth/utils.test.ts # core/mcp/node/transport.ts # test-servers/src/composable-test-server.ts
This commit is contained in:
commit
cfbdfdf2bd
48 changed files with 15921 additions and 1202 deletions
|
|
@ -412,3 +412,13 @@
|
|||
.server-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sticky re-auth banner bar. Positioning (pos/top) and background are set via
|
||||
* Mantine props on the Box; z-index and box-shadow have no Mantine style-prop
|
||||
* shorthand, so they live here.
|
||||
*/
|
||||
.reauth-banner-bar {
|
||||
z-index: 200;
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 +412,13 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
}));
|
||||
|
||||
import App from "./App";
|
||||
import { OAUTH_PENDING_SERVER_KEY } from "./utils/oauthFlow";
|
||||
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";
|
||||
|
|
@ -1627,15 +1641,18 @@ describe("App OAuth callback state validation", () => {
|
|||
|
||||
it("does not reject when the state param parses to a valid authId", async () => {
|
||||
// A well-formed 64-char-hex state passes the shape guard, so the handler
|
||||
// proceeds to the server-matching step. With no server registered under the
|
||||
// seeded pending id, that step surfaces the "could not be matched" toast —
|
||||
// asserting on that specific downstream toast proves the state was accepted
|
||||
// (never the "OAuth callback rejected" toast) rather than relying on an
|
||||
// indirect "some toast fired" check.
|
||||
window.sessionStorage.setItem(
|
||||
OAUTH_PENDING_SERVER_KEY,
|
||||
"server-that-does-not-exist",
|
||||
);
|
||||
// proceeds to the server-matching step. With the resume snapshot pointing at
|
||||
// a server id that is not registered, that step surfaces the "could not be
|
||||
// matched" toast — asserting on that specific downstream toast proves the
|
||||
// state was accepted (never the "OAuth callback rejected" toast) rather than
|
||||
// relying on an indirect "some toast fired" check.
|
||||
writeOAuthResumeSnapshot({
|
||||
version: 1,
|
||||
serverId: "server-that-does-not-exist",
|
||||
activeTab: "Tools",
|
||||
authKind: "reauth",
|
||||
tabUi: {},
|
||||
});
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
|
|
@ -1656,3 +1673,152 @@ describe("App OAuth callback state validation", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
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
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { Alert, Button, Group, Text } from "@mantine/core";
|
||||
|
||||
export interface ReAuthBannerProps {
|
||||
message: string;
|
||||
onReauthenticate: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function ReAuthBanner({
|
||||
message,
|
||||
onReauthenticate,
|
||||
onDismiss,
|
||||
}: ReAuthBannerProps) {
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="reauth"
|
||||
title="Re-authentication required"
|
||||
withCloseButton
|
||||
onClose={onDismiss}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
|
||||
<Text component="span" size="sm">
|
||||
{message}
|
||||
</Text>
|
||||
<Button size="xs" variant="filled" onClick={onReauthenticate}>
|
||||
Re-authenticate
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
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;
|
||||
const handleAuthorize = () => void onAuthorize();
|
||||
|
||||
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={handleAuthorize}>Authorize</Button>
|
||||
</Actions>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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({ delay: null });
|
||||
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({ delay: null });
|
||||
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({ delay: null });
|
||||
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({ delay: null });
|
||||
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({ delay: null });
|
||||
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({ delay: null });
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
393
clients/web/src/test/core/auth/challenge.test.ts
Normal file
393
clients/web/src/test/core/auth/challenge.test.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
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();
|
||||
});
|
||||
|
||||
it("carries error_description into the challenge message", () => {
|
||||
const response = new Response(null, {
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate":
|
||||
'Bearer error="invalid_token", error_description="Token expired"',
|
||||
},
|
||||
});
|
||||
|
||||
expect(parseAuthChallengeFromResponse(response)).toEqual({
|
||||
reason: "invalid_token",
|
||||
message: "Token expired",
|
||||
raw: {
|
||||
httpStatus: 401,
|
||||
wwwAuthenticate:
|
||||
'Bearer error="invalid_token", error_description="Token expired"',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps 403 with a non-scope error to unauthorized", () => {
|
||||
const response = new Response(null, {
|
||||
status: 403,
|
||||
headers: {
|
||||
"WWW-Authenticate": 'Bearer error="invalid_token"',
|
||||
},
|
||||
});
|
||||
|
||||
expect(parseAuthChallengeFromResponse(response)?.reason).toBe(
|
||||
"unauthorized",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("returns the challenge directly for AuthChallengeError instances", () => {
|
||||
const err = new AuthChallengeError({ reason: "invalid_token" }, 401);
|
||||
expect(parseAuthChallengeFromError(err)).toEqual({
|
||||
reason: "invalid_token",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined for non-object and null errors", () => {
|
||||
expect(parseAuthChallengeFromError("boom")).toBeUndefined();
|
||||
expect(parseAuthChallengeFromError(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges context into an embedded authChallenge", () => {
|
||||
expect(
|
||||
parseAuthChallengeFromError(
|
||||
{
|
||||
authChallenge: { reason: "token_expired", context: { method: "x" } },
|
||||
},
|
||||
{ toolName: "get_temp" },
|
||||
),
|
||||
).toEqual({
|
||||
reason: "token_expired",
|
||||
context: { method: "x", toolName: "get_temp" },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the numeric code when status is absent", () => {
|
||||
expect(
|
||||
parseAuthChallengeFromError({
|
||||
code: 403,
|
||||
wwwAuthenticate: 'Bearer error="insufficient_scope", scope="admin"',
|
||||
}),
|
||||
).toMatchObject({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["admin"],
|
||||
raw: { httpStatus: 403 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined when the status is neither 401 nor 403", () => {
|
||||
expect(parseAuthChallengeFromError({ status: 500 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads WWW-Authenticate from a headers.get accessor", () => {
|
||||
expect(
|
||||
parseAuthChallengeFromError({
|
||||
status: 401,
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name === "WWW-Authenticate" ? 'Bearer error="invalid_token"' : null,
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
reason: "invalid_token",
|
||||
raw: { httpStatus: 401, wwwAuthenticate: 'Bearer error="invalid_token"' },
|
||||
});
|
||||
});
|
||||
|
||||
it("reads WWW-Authenticate from an embedded raw challenge", () => {
|
||||
expect(
|
||||
parseAuthChallengeFromError({
|
||||
status: 401,
|
||||
authChallenge: { raw: { wwwAuthenticate: "Bearer realm=mcp" } },
|
||||
}),
|
||||
).toMatchObject({
|
||||
reason: "token_expired",
|
||||
raw: { httpStatus: 401, wwwAuthenticate: "Bearer realm=mcp" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined for an empty WWW-Authenticate header", () => {
|
||||
expect(
|
||||
parseAuthChallengeFromError({ status: 401, wwwAuthenticate: "" }),
|
||||
).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);
|
||||
});
|
||||
|
||||
it("returns false for non-object and null errors", () => {
|
||||
expect(isAuthChallengeError("boom")).toBe(false);
|
||||
expect(isAuthChallengeError(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects an embedded authChallenge with a reason", () => {
|
||||
expect(
|
||||
isAuthChallengeError({ authChallenge: { reason: "token_expired" } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the numeric code when status is absent", () => {
|
||||
expect(
|
||||
isAuthChallengeError({
|
||||
code: 403,
|
||||
wwwAuthenticate: 'Bearer error="insufficient_scope"',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reads WWW-Authenticate from a headers.get accessor", () => {
|
||||
expect(
|
||||
isAuthChallengeError({
|
||||
status: 401,
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name === "WWW-Authenticate" ? "Bearer realm=mcp" : null,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reads WWW-Authenticate from an embedded raw challenge", () => {
|
||||
expect(
|
||||
isAuthChallengeError({
|
||||
status: 401,
|
||||
authChallenge: { raw: { wwwAuthenticate: "Bearer realm=mcp" } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an empty WWW-Authenticate header", () => {
|
||||
expect(isAuthChallengeError({ status: 401, wwwAuthenticate: "" })).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);
|
||||
});
|
||||
});
|
||||
315
clients/web/src/test/core/auth/mcpAuth.test.ts
Normal file
315
clients/web/src/test/core/auth/mcpAuth.test.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
|
||||
const {
|
||||
sdkAuth,
|
||||
discoverOAuthServerInfo,
|
||||
startAuthorization,
|
||||
selectResourceURL,
|
||||
registerClient,
|
||||
isHttpsUrl,
|
||||
} = vi.hoisted(() => ({
|
||||
sdkAuth: vi.fn(),
|
||||
discoverOAuthServerInfo: vi.fn(),
|
||||
startAuthorization: vi.fn(),
|
||||
selectResourceURL: vi.fn(),
|
||||
registerClient: vi.fn(),
|
||||
isHttpsUrl: 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,
|
||||
registerClient,
|
||||
isHttpsUrl,
|
||||
};
|
||||
});
|
||||
|
||||
import { mcpAuth } from "@inspector/core/auth/mcpAuth.js";
|
||||
import { InvalidClientMetadataError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
||||
|
||||
function makeProvider(
|
||||
overrides: Partial<OAuthClientProvider> = {},
|
||||
): 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"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange the discovery + authorization mocks used by the
|
||||
* `forceReauthorization` (authorize-without-refresh) path.
|
||||
*/
|
||||
function stubForcePath(
|
||||
discovery: Partial<{
|
||||
authorizationServerUrl: string;
|
||||
authorizationServerMetadata: Record<string, unknown>;
|
||||
resourceMetadata: unknown;
|
||||
}> = {},
|
||||
): void {
|
||||
discoverOAuthServerInfo.mockResolvedValue({
|
||||
authorizationServerUrl: "https://as.example.com",
|
||||
authorizationServerMetadata: { issuer: "https://as.example.com" },
|
||||
resourceMetadata: undefined,
|
||||
...discovery,
|
||||
});
|
||||
selectResourceURL.mockResolvedValue(undefined);
|
||||
startAuthorization.mockResolvedValue({
|
||||
authorizationUrl: new URL("https://as.example.com/authorize"),
|
||||
codeVerifier: "cv",
|
||||
});
|
||||
}
|
||||
|
||||
describe("mcpAuth", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isHttpsUrl.mockReturnValue(true);
|
||||
});
|
||||
|
||||
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("rejects forceReauthorization combined with authorizationCode", async () => {
|
||||
const provider = makeProvider();
|
||||
|
||||
await expect(
|
||||
mcpAuth(provider, {
|
||||
serverUrl: "https://mcp.example.com",
|
||||
authorizationCode: "code",
|
||||
forceReauthorization: true,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"forceReauthorization cannot be combined with authorizationCode",
|
||||
);
|
||||
expect(sdkAuth).not.toHaveBeenCalled();
|
||||
expect(discoverOAuthServerInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses discovery + startAuthorization when forceReauthorization is true", async () => {
|
||||
stubForcePath();
|
||||
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();
|
||||
});
|
||||
|
||||
it("persists discovery state and forwards the selected resource + state", async () => {
|
||||
stubForcePath({
|
||||
resourceMetadata: { resource: "https://mcp.example.com" },
|
||||
});
|
||||
selectResourceURL.mockResolvedValue(new URL("https://mcp.example.com/r"));
|
||||
const saveDiscoveryState = vi.fn().mockResolvedValue(undefined);
|
||||
const state = vi.fn().mockResolvedValue("state-123");
|
||||
const provider = makeProvider({ saveDiscoveryState, state });
|
||||
|
||||
await mcpAuth(provider, {
|
||||
serverUrl: "https://mcp.example.com",
|
||||
resourceMetadataUrl: new URL("https://mcp.example.com/.well-known/x"),
|
||||
forceReauthorization: true,
|
||||
});
|
||||
|
||||
expect(saveDiscoveryState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authorizationServerUrl: "https://as.example.com",
|
||||
resourceMetadataUrl: "https://mcp.example.com/.well-known/x",
|
||||
}),
|
||||
);
|
||||
expect(state).toHaveBeenCalled();
|
||||
expect(startAuthorization).toHaveBeenCalledWith(
|
||||
"https://as.example.com",
|
||||
expect.objectContaining({
|
||||
state: "state-123",
|
||||
resource: new URL("https://mcp.example.com/r"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("derives scope from resourceMetadata.scopes_supported when no scope given", async () => {
|
||||
stubForcePath({
|
||||
resourceMetadata: { scopes_supported: ["mcp", "weather:read"] },
|
||||
});
|
||||
const provider = makeProvider();
|
||||
|
||||
await mcpAuth(provider, {
|
||||
serverUrl: "https://mcp.example.com",
|
||||
forceReauthorization: true,
|
||||
});
|
||||
|
||||
expect(startAuthorization).toHaveBeenCalledWith(
|
||||
"https://as.example.com",
|
||||
expect.objectContaining({ scope: "mcp weather:read" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to clientMetadata.scope when nothing else provides scope", async () => {
|
||||
stubForcePath();
|
||||
const provider = makeProvider({
|
||||
clientMetadata: {
|
||||
...makeProvider().clientMetadata,
|
||||
scope: "fallback:scope",
|
||||
},
|
||||
});
|
||||
|
||||
await mcpAuth(provider, {
|
||||
serverUrl: "https://mcp.example.com",
|
||||
forceReauthorization: true,
|
||||
});
|
||||
|
||||
expect(startAuthorization).toHaveBeenCalledWith(
|
||||
"https://as.example.com",
|
||||
expect.objectContaining({ scope: "fallback:scope" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("dynamically registers a client when no client information exists", async () => {
|
||||
stubForcePath();
|
||||
registerClient.mockResolvedValue({ client_id: "registered" });
|
||||
const saveClientInformation = vi.fn().mockResolvedValue(undefined);
|
||||
const provider = makeProvider({
|
||||
clientInformation: vi.fn().mockResolvedValue(undefined),
|
||||
saveClientInformation,
|
||||
});
|
||||
|
||||
const result = await mcpAuth(provider, {
|
||||
serverUrl: "https://mcp.example.com",
|
||||
scope: "mcp",
|
||||
forceReauthorization: true,
|
||||
});
|
||||
|
||||
expect(result).toBe("REDIRECT");
|
||||
expect(registerClient).toHaveBeenCalledWith(
|
||||
"https://as.example.com",
|
||||
expect.objectContaining({ scope: "mcp" }),
|
||||
);
|
||||
expect(saveClientInformation).toHaveBeenCalledWith({
|
||||
client_id: "registered",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a URL-based client id when the AS supports it", async () => {
|
||||
stubForcePath({
|
||||
authorizationServerMetadata: {
|
||||
issuer: "https://as.example.com",
|
||||
client_id_metadata_document_supported: true,
|
||||
},
|
||||
});
|
||||
isHttpsUrl.mockReturnValue(true);
|
||||
const saveClientInformation = vi.fn().mockResolvedValue(undefined);
|
||||
const provider = makeProvider({
|
||||
clientInformation: vi.fn().mockResolvedValue(undefined),
|
||||
clientMetadataUrl: "https://app.example.com/client.json",
|
||||
saveClientInformation,
|
||||
});
|
||||
|
||||
await mcpAuth(provider, {
|
||||
serverUrl: "https://mcp.example.com",
|
||||
forceReauthorization: true,
|
||||
});
|
||||
|
||||
expect(registerClient).not.toHaveBeenCalled();
|
||||
expect(saveClientInformation).toHaveBeenCalledWith({
|
||||
client_id: "https://app.example.com/client.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-HTTPS clientMetadataUrl", async () => {
|
||||
stubForcePath();
|
||||
isHttpsUrl.mockReturnValue(false);
|
||||
const provider = makeProvider({
|
||||
clientInformation: vi.fn().mockResolvedValue(undefined),
|
||||
clientMetadataUrl: "http://insecure.example.com/client.json",
|
||||
saveClientInformation: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
mcpAuth(provider, {
|
||||
serverUrl: "https://mcp.example.com",
|
||||
forceReauthorization: true,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InvalidClientMetadataError);
|
||||
expect(registerClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires saveClientInformation for dynamic registration", async () => {
|
||||
stubForcePath();
|
||||
const provider = makeProvider({
|
||||
clientInformation: vi.fn().mockResolvedValue(undefined),
|
||||
saveClientInformation: undefined,
|
||||
});
|
||||
|
||||
await expect(
|
||||
mcpAuth(provider, {
|
||||
serverUrl: "https://mcp.example.com",
|
||||
forceReauthorization: true,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"OAuth client information must be saveable for dynamic registration",
|
||||
);
|
||||
expect(registerClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a redirectUrl for the authorization_code flow", async () => {
|
||||
stubForcePath();
|
||||
const provider = makeProvider({ redirectUrl: undefined });
|
||||
|
||||
await expect(
|
||||
mcpAuth(provider, {
|
||||
serverUrl: "https://mcp.example.com",
|
||||
forceReauthorization: true,
|
||||
}),
|
||||
).rejects.toThrow("redirectUrl is required for authorization_code flow");
|
||||
expect(startAuthorization).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
336
clients/web/src/test/core/auth/oauthUx.test.ts
Normal file
336
clients/web/src/test/core/auth/oauthUx.test.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
authRecoveryRestoredMessage,
|
||||
emaStepUpFailureMessage,
|
||||
emaStepUpInProgressMessage,
|
||||
emaStepUpSuccessMessage,
|
||||
isActionTriggeredOAuthRecovery,
|
||||
isEmaStepUp,
|
||||
isReAuthBannerReason,
|
||||
isStandardOAuthStepUp,
|
||||
isStepUpConfirmation,
|
||||
oauthPreRedirectToastCopy,
|
||||
oauthResumeAbandonedMessage,
|
||||
oauthResumeSuccessMessage,
|
||||
reAuthBannerMessage,
|
||||
stepUpAdditionalScopes,
|
||||
stepUpConfirmMessage,
|
||||
stepUpFollowUpMessage,
|
||||
stepUpInsufficientScopeMessage,
|
||||
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 plural label and organization language for multiple EMA scopes", () => {
|
||||
expect(
|
||||
stepUpConfirmMessage(
|
||||
{
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read", "weather:write"],
|
||||
},
|
||||
{ enterpriseManaged: true },
|
||||
),
|
||||
).toBe(
|
||||
"This operation needs additional organization scopes: weather:read, weather:write.",
|
||||
);
|
||||
});
|
||||
|
||||
it("stepUpConfirmMessage uses plural label for multiple standard scopes", () => {
|
||||
expect(
|
||||
stepUpConfirmMessage({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read", "weather:write"],
|
||||
}),
|
||||
).toBe(
|
||||
"This operation needs additional scopes: weather:read, weather:write.",
|
||||
);
|
||||
});
|
||||
|
||||
it("stepUpConfirmMessage falls back to generic standard copy with no tool or scopes", () => {
|
||||
expect(stepUpConfirmMessage({ reason: "insufficient_scope" })).toBe(
|
||||
"This operation needs additional OAuth scopes before it can continue.",
|
||||
);
|
||||
});
|
||||
|
||||
it("stepUpConfirmMessage falls back to generic EMA copy with no tool or scopes", () => {
|
||||
expect(
|
||||
stepUpConfirmMessage(
|
||||
{ reason: "insufficient_scope", requiredScopes: ["", ""] },
|
||||
{ enterpriseManaged: true },
|
||||
),
|
||||
).toBe(
|
||||
"This operation needs additional permissions from your organization before it can continue.",
|
||||
);
|
||||
});
|
||||
|
||||
it("stepUpConfirmMessage uses organization language for EMA", () => {
|
||||
expect(
|
||||
stepUpConfirmMessage(challenge, { enterpriseManaged: true }),
|
||||
).toMatch(/organization/i);
|
||||
expect(stepUpFollowUpMessage({ enterpriseManaged: true })).toMatch(
|
||||
/identity provider/i,
|
||||
);
|
||||
expect(stepUpFollowUpMessage()).toMatch(/redirected to authorize/i);
|
||||
expect(stepUpModalTitle({ enterpriseManaged: true })).toMatch(
|
||||
/organization/i,
|
||||
);
|
||||
expect(stepUpModalTitle()).toBe("Additional permissions required");
|
||||
expect(stepUpAuthorizeActionLabel({ enterpriseManaged: true })).toBe(
|
||||
"Authorize",
|
||||
);
|
||||
expect(stepUpAuthorizeActionLabel()).toBe("Authorize (opens browser)");
|
||||
});
|
||||
|
||||
it("isStandardOAuthStepUp is true only for non-EMA insufficient_scope", () => {
|
||||
expect(isStandardOAuthStepUp(challenge)).toBe(true);
|
||||
expect(isStandardOAuthStepUp(challenge, { enterpriseManaged: true })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isStandardOAuthStepUp({ reason: "token_expired" })).toBe(false);
|
||||
});
|
||||
|
||||
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("emaStepUpInProgressMessage describes requesting organization permissions", () => {
|
||||
expect(emaStepUpInProgressMessage()).toMatch(/organization/i);
|
||||
});
|
||||
|
||||
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("emaStepUpFailureMessage returns detail when present, else generic copy", () => {
|
||||
expect(emaStepUpFailureMessage("boom")).toBe("boom");
|
||||
expect(emaStepUpFailureMessage(" ")).toBe(
|
||||
"Could not obtain the additional permissions from your organization.",
|
||||
);
|
||||
expect(emaStepUpFailureMessage()).toBe(
|
||||
"Could not obtain the additional permissions from your organization.",
|
||||
);
|
||||
});
|
||||
|
||||
it("stepUpAdditionalScopes returns requiredScopes only", () => {
|
||||
expect(stepUpAdditionalScopes(challenge)).toEqual(["weather:read"]);
|
||||
});
|
||||
|
||||
it("stepUpAdditionalScopes returns empty array when requiredScopes undefined", () => {
|
||||
expect(stepUpAdditionalScopes({ reason: "insufficient_scope" })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauthUx recovery-source predicates", () => {
|
||||
it("isActionTriggeredOAuthRecovery is true for action sources, false otherwise", () => {
|
||||
for (const source of ["tool", "prompt", "resource", "app"] as const) {
|
||||
expect(isActionTriggeredOAuthRecovery(source)).toBe(true);
|
||||
}
|
||||
expect(isActionTriggeredOAuthRecovery("ambient")).toBe(false);
|
||||
expect(isActionTriggeredOAuthRecovery(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauthUx resume/restore copy", () => {
|
||||
it("oauthResumeSuccessMessage step_up varies with retry", () => {
|
||||
expect(
|
||||
oauthResumeSuccessMessage("step_up", { recoverySource: "tool" }),
|
||||
).toBe("Step-up authorization succeeded. Retry your action.");
|
||||
expect(oauthResumeSuccessMessage("step_up")).toBe(
|
||||
"Step-up authorization succeeded.",
|
||||
);
|
||||
});
|
||||
|
||||
it("oauthResumeSuccessMessage reauth varies with retry", () => {
|
||||
expect(
|
||||
oauthResumeSuccessMessage("reauth", { recoverySource: "prompt" }),
|
||||
).toBe("Authentication succeeded. Retry your action.");
|
||||
expect(oauthResumeSuccessMessage("reauth")).toBe(
|
||||
"Authentication succeeded.",
|
||||
);
|
||||
});
|
||||
|
||||
it("authRecoveryRestoredMessage varies with retry", () => {
|
||||
expect(authRecoveryRestoredMessage({ recoverySource: "resource" })).toBe(
|
||||
"Session credentials were updated. Retry your action.",
|
||||
);
|
||||
expect(authRecoveryRestoredMessage()).toBe(
|
||||
"Session credentials were updated.",
|
||||
);
|
||||
});
|
||||
|
||||
it("oauthResumeAbandonedMessage reauth is retry-agnostic", () => {
|
||||
expect(
|
||||
oauthResumeAbandonedMessage("reauth", { recoverySource: "tool" }),
|
||||
).toBe("Sign-in was not completed. Re-authenticate to restore access.");
|
||||
expect(oauthResumeAbandonedMessage("reauth")).toBe(
|
||||
"Sign-in was not completed. Re-authenticate to restore access.",
|
||||
);
|
||||
});
|
||||
|
||||
it("oauthResumeAbandonedMessage step_up varies with retry", () => {
|
||||
expect(
|
||||
oauthResumeAbandonedMessage("step_up", { recoverySource: "app" }),
|
||||
).toBe("Step-up authorization was not completed. Retry your action.");
|
||||
expect(oauthResumeAbandonedMessage("step_up")).toBe(
|
||||
"Step-up authorization was not completed.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauthUx insufficient-scope resolution copy", () => {
|
||||
it("prefers tool context", () => {
|
||||
expect(
|
||||
stepUpInsufficientScopeMessage({
|
||||
reason: "insufficient_scope",
|
||||
context: { toolName: "get_temp" },
|
||||
}),
|
||||
).toMatch(/tool "get_temp"/);
|
||||
});
|
||||
|
||||
it("uses authorizationScopes when present and no tool", () => {
|
||||
expect(
|
||||
stepUpInsufficientScopeMessage({
|
||||
reason: "insufficient_scope",
|
||||
authorizationScopes: ["a", "b"],
|
||||
requiredScopes: ["c"],
|
||||
}),
|
||||
).toBe(
|
||||
"Authorization completed, but required scopes were not granted (a, b). Grant the requested permissions on the authorization server, then retry your action.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to requiredScopes when authorizationScopes absent", () => {
|
||||
expect(
|
||||
stepUpInsufficientScopeMessage({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["c"],
|
||||
}),
|
||||
).toMatch(/\(c\)/);
|
||||
});
|
||||
|
||||
it("uses generic copy when no tool or scopes", () => {
|
||||
expect(
|
||||
stepUpInsufficientScopeMessage({ reason: "insufficient_scope" }),
|
||||
).toBe(
|
||||
"Authorization completed, but the required permissions were not granted. Grant the requested scopes on the authorization server, then retry your action.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauthUx pre-redirect toast copy", () => {
|
||||
it("returns undefined for a fresh connect handshake", () => {
|
||||
expect(
|
||||
oauthPreRedirectToastCopy("reauth", { context: "connect" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("step_up toast includes server name when provided", () => {
|
||||
expect(oauthPreRedirectToastCopy("step_up", { serverName: "svc" })).toEqual(
|
||||
{
|
||||
title: 'Step-up authorization for "svc"',
|
||||
message: "Redirecting to authorize additional permissions…",
|
||||
},
|
||||
);
|
||||
expect(oauthPreRedirectToastCopy("step_up", {})).toEqual({
|
||||
title: "Step-up authorization",
|
||||
message: "Redirecting to authorize additional permissions…",
|
||||
});
|
||||
});
|
||||
|
||||
it("enterprise-managed reauth toast re-authenticates", () => {
|
||||
expect(
|
||||
oauthPreRedirectToastCopy("reauth", {
|
||||
serverName: "svc",
|
||||
enterpriseManaged: true,
|
||||
}),
|
||||
).toEqual({
|
||||
title: 'Re-authenticating "svc"',
|
||||
message: "Re-authenticating…",
|
||||
});
|
||||
expect(
|
||||
oauthPreRedirectToastCopy("reauth", { enterpriseManaged: true }),
|
||||
).toEqual({ title: "Re-authenticating", message: "Re-authenticating…" });
|
||||
});
|
||||
|
||||
it("default reauth toast signals an expired session", () => {
|
||||
expect(oauthPreRedirectToastCopy("reauth", { serverName: "svc" })).toEqual({
|
||||
title: 'Session expired for "svc"',
|
||||
message: "Session expired, re-authenticating…",
|
||||
});
|
||||
expect(oauthPreRedirectToastCopy("reauth", {})).toEqual({
|
||||
title: "Session expired",
|
||||
message: "Session expired, re-authenticating…",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauthUx re-auth banner", () => {
|
||||
it("isReAuthBannerReason is true for degraded-session reasons", () => {
|
||||
for (const reason of [
|
||||
"token_expired",
|
||||
"unauthorized",
|
||||
"invalid_token",
|
||||
] as const) {
|
||||
expect(isReAuthBannerReason(reason)).toBe(true);
|
||||
}
|
||||
expect(isReAuthBannerReason("insufficient_scope")).toBe(false);
|
||||
expect(isReAuthBannerReason(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("reAuthBannerMessage varies with server name and detail", () => {
|
||||
expect(
|
||||
reAuthBannerMessage({ serverName: "svc", detail: "Token expired." }),
|
||||
).toBe('Authentication for "svc" needs attention. Token expired.');
|
||||
expect(reAuthBannerMessage({ serverName: "svc" })).toBe(
|
||||
'Authentication for "svc" needs attention.',
|
||||
);
|
||||
expect(reAuthBannerMessage({ detail: "Token expired." })).toBe(
|
||||
"Authentication needs attention. Token expired.",
|
||||
);
|
||||
expect(reAuthBannerMessage({})).toBe("Authentication needs attention.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
442
clients/web/src/test/core/auth/runner-interactive-oauth.test.ts
Normal file
442
clients/web/src/test/core/auth/runner-interactive-oauth.test.ts
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
runRunnerInteractiveOAuth,
|
||||
type RunnerInteractiveOAuthClient,
|
||||
} from "@inspector/core/auth/node/runner-interactive-oauth.js";
|
||||
import {
|
||||
createOAuthCallbackServer,
|
||||
type OAuthCallbackServer,
|
||||
type OAuthCallbackServerStartOptions,
|
||||
} from "@inspector/core/auth/node/oauth-callback-server.js";
|
||||
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||
|
||||
vi.mock(
|
||||
"@inspector/core/auth/node/oauth-callback-server.js",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@inspector/core/auth/node/oauth-callback-server.js")
|
||||
>();
|
||||
return { ...actual, createOAuthCallbackServer: vi.fn() };
|
||||
},
|
||||
);
|
||||
|
||||
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("clears the callback timeout on already_authorized", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
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",
|
||||
},
|
||||
callbackTimeoutMs: 60_000,
|
||||
createCallbackServer: () => createMockCallbackServer(handlers),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "already_authorized" });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("wraps a non-Error completeOAuthFlow rejection", async () => {
|
||||
const redirectUrlProvider = { redirectUrl: "" };
|
||||
const client = mockClient({
|
||||
authenticate: vi.fn(async () => {
|
||||
await simulateCallback(handlers.current).catch(() => {});
|
||||
return new URL("https://as.example/authorize");
|
||||
}),
|
||||
completeOAuthFlow: vi.fn(async () => {
|
||||
throw "string failure";
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRunnerInteractiveOAuth({
|
||||
client,
|
||||
redirectUrlProvider,
|
||||
callbackListen: {
|
||||
hostname: "127.0.0.1",
|
||||
port: 6276,
|
||||
pathname: "/oauth/callback",
|
||||
},
|
||||
createCallbackServer: () => createMockCallbackServer(handlers),
|
||||
}),
|
||||
).rejects.toThrow("string failure");
|
||||
});
|
||||
|
||||
it("falls back to params.error when no error_description is present", async () => {
|
||||
const redirectUrlProvider = { redirectUrl: "" };
|
||||
const client = mockClient({
|
||||
authenticate: vi.fn(async () => {
|
||||
handlers.current.onError?.({ error: "access_denied" });
|
||||
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("access_denied");
|
||||
});
|
||||
|
||||
it("defaults to createOAuthCallbackServer when none is provided", async () => {
|
||||
const redirectUrlProvider = { redirectUrl: "" };
|
||||
vi.mocked(createOAuthCallbackServer).mockReturnValue(
|
||||
createMockCallbackServer(handlers),
|
||||
);
|
||||
const client = mockClient({
|
||||
authenticate: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const result = await runRunnerInteractiveOAuth({
|
||||
client,
|
||||
redirectUrlProvider,
|
||||
callbackListen: {
|
||||
hostname: "127.0.0.1",
|
||||
port: 6276,
|
||||
pathname: "/oauth/callback",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "already_authorized" });
|
||||
expect(createOAuthCallbackServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows a server.stop() rejection during cleanup", async () => {
|
||||
const redirectUrlProvider = { redirectUrl: "" };
|
||||
const mockServer = createMockCallbackServer(handlers);
|
||||
vi.mocked(mockServer.stop).mockRejectedValue(new Error("stop failed"));
|
||||
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: () => mockServer,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "success" });
|
||||
expect(mockServer.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans up when the callback server fails to start", async () => {
|
||||
const redirectUrlProvider = { redirectUrl: "" };
|
||||
const mockServer = createMockCallbackServer(handlers);
|
||||
vi.mocked(mockServer.start).mockRejectedValue(new Error("bind failed"));
|
||||
const client = mockClient();
|
||||
|
||||
await expect(
|
||||
runRunnerInteractiveOAuth({
|
||||
client,
|
||||
redirectUrlProvider,
|
||||
callbackListen: {
|
||||
hostname: "127.0.0.1",
|
||||
port: 6276,
|
||||
pathname: "/oauth/callback",
|
||||
},
|
||||
createCallbackServer: () => mockServer,
|
||||
}),
|
||||
).rejects.toThrow("bind failed");
|
||||
expect(mockServer.stop).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
67
clients/web/src/test/core/auth/scopes.test.ts
Normal file
67
clients/web/src/test/core/auth/scopes.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeScopeUnion,
|
||||
isStrictScopeSuperset,
|
||||
resolveEffectiveGrantedScope,
|
||||
resolvePersistedScopeAfterGrant,
|
||||
} 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePersistedScopeAfterGrant", () => {
|
||||
it("prefers explicit granted scope over requested", () => {
|
||||
expect(resolvePersistedScopeAfterGrant("mcp", "mcp weather:read")).toBe(
|
||||
"mcp",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to requested scope when grant omits scope", () => {
|
||||
expect(
|
||||
resolvePersistedScopeAfterGrant(undefined, "mcp weather:read"),
|
||||
).toBe("mcp weather:read");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveGrantedScope", () => {
|
||||
it("uses token scope when present even if storage overstates grant", () => {
|
||||
expect(resolveEffectiveGrantedScope("mcp weather:read", "mcp")).toBe(
|
||||
"mcp",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to stored scope when token omits scope", () => {
|
||||
expect(resolveEffectiveGrantedScope("mcp tools:read", undefined)).toBe(
|
||||
"mcp tools:read",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,10 @@ import {
|
|||
generateOAuthState,
|
||||
parseOAuthState,
|
||||
generateOAuthErrorDescription,
|
||||
formatOAuthFailureDetail,
|
||||
isUnauthorizedError,
|
||||
} from "@inspector/core/auth/utils.js";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
describe("parseHttpUrl", () => {
|
||||
it("parses valid URLs", () => {
|
||||
|
|
@ -17,6 +20,12 @@ describe("parseHttpUrl", () => {
|
|||
it("throws on invalid URLs", () => {
|
||||
expect(() => parseHttpUrl("not-a-url", "test")).toThrow(/Invalid test/);
|
||||
});
|
||||
|
||||
it("includes the trimmed offending value in the thrown message", () => {
|
||||
expect(() => parseHttpUrl(" bad url ", "Server URL")).toThrow(
|
||||
/Invalid Server URL: "bad url"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOAuthCallbackParams", () => {
|
||||
|
|
@ -129,3 +138,152 @@ 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",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes through plain strings that are not bracketed JSON", () => {
|
||||
expect(formatOAuthFailureDetail("something went wrong")).toBe(
|
||||
"something went wrong",
|
||||
);
|
||||
});
|
||||
|
||||
it("stringifies non-string/non-error values", () => {
|
||||
expect(formatOAuthFailureDetail(42)).toBe("42");
|
||||
expect(formatOAuthFailureDetail(null)).toBe("null");
|
||||
});
|
||||
|
||||
it("formats a real ZodError instance (non-token path)", () => {
|
||||
const err = z.string().safeParse(123).error!;
|
||||
const result = formatOAuthFailureDetail(err);
|
||||
expect(result).not.toMatch(/valid tokens/i);
|
||||
// Root-level failure has an empty path → "input" label
|
||||
expect(result).toMatch(/^input: /);
|
||||
});
|
||||
|
||||
it("joins non-token issues as `path: message`", () => {
|
||||
const err = new ZodError([
|
||||
{
|
||||
code: "invalid_type",
|
||||
expected: "string",
|
||||
path: ["client", "id"],
|
||||
message: "Required",
|
||||
},
|
||||
]);
|
||||
expect(formatOAuthFailureDetail(err)).toBe("client.id: Required");
|
||||
});
|
||||
|
||||
it("labels an issue with an empty path as `input` and defaults a missing message to `invalid`", () => {
|
||||
const err = new ZodError([
|
||||
{
|
||||
code: "custom",
|
||||
path: [],
|
||||
} as unknown as z.core.$ZodIssue,
|
||||
]);
|
||||
expect(formatOAuthFailureDetail(err)).toBe("input: invalid");
|
||||
});
|
||||
|
||||
it("formats a bracketed JSON string of non-token zod issues", () => {
|
||||
const json = `[ { "code": "invalid_type", "path": [ "scope" ], "message": "Required" } ]`;
|
||||
expect(formatOAuthFailureDetail(json)).toBe("scope: Required");
|
||||
});
|
||||
|
||||
it("returns the raw string when bracketed JSON is invalid", () => {
|
||||
const bad = "[ not valid json";
|
||||
expect(formatOAuthFailureDetail(bad)).toBe(bad);
|
||||
});
|
||||
|
||||
it("returns the raw string when bracketed JSON is not a zod-issue array", () => {
|
||||
// Parses fine, but empty array / first element lacks `code`
|
||||
expect(formatOAuthFailureDetail("[]")).toBe("[]");
|
||||
const notIssues = `[ { "foo": "bar" } ]`;
|
||||
expect(formatOAuthFailureDetail(notIssues)).toBe(notIssues);
|
||||
});
|
||||
|
||||
it("returns the raw string when bracketed JSON is a non-array value", () => {
|
||||
const obj = `[1, 2, 3]`;
|
||||
// Array of numbers: first element is not an object → not a zod-issue array
|
||||
expect(formatOAuthFailureDetail(obj)).toBe(obj);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUnauthorizedError", () => {
|
||||
it("returns true for an object with status 401", () => {
|
||||
expect(isUnauthorizedError({ status: 401 })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for an object with code 401", () => {
|
||||
expect(isUnauthorizedError({ code: 401 })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when an Error message matches the transport wording", () => {
|
||||
expect(
|
||||
isUnauthorizedError(new Error("Connection failed for server (401)")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when a non-error value stringifies to matching wording", () => {
|
||||
expect(isUnauthorizedError("request failed with status (401)")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated (401) mentions without `failed`", () => {
|
||||
expect(isUnauthorizedError(new Error("error code (401) noted"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for a non-401 object", () => {
|
||||
expect(isUnauthorizedError({ status: 500 })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isUnauthorizedError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthErrorDescription without description/uri", () => {
|
||||
it("omits the details and more-info lines when absent", () => {
|
||||
const message = generateOAuthErrorDescription({
|
||||
successful: false,
|
||||
error: "server_error",
|
||||
error_description: null,
|
||||
error_uri: null,
|
||||
});
|
||||
expect(message).toBe("Error: server_error.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOAuthCallbackParams error without description", () => {
|
||||
it("returns null description/uri when only error is present", () => {
|
||||
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
|
||||
successful: false,
|
||||
error: "access_denied",
|
||||
error_description: null,
|
||||
error_uri: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
288
clients/web/src/test/core/mcp/authResyncPropagation.test.ts
Normal file
288
clients/web/src/test/core/mcp/authResyncPropagation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue