Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
This commit is contained in:
parent
5ee347571f
commit
5de4e911e3
24 changed files with 7585 additions and 595 deletions
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { ClientSettingsForm } from "./ClientSettingsForm";
|
||||
import { EMPTY_CLIENT_SETTINGS } from "./clientSettingsValues";
|
||||
|
||||
describe("ClientSettingsForm EMA IdP session", () => {
|
||||
it("shows signed-in state and sign out", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onLogout = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
}}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="logged_in"
|
||||
onEmaIdpLogout={onLogout}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Signed in")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Sign out" }));
|
||||
expect(onLogout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows not signed in without sign out button", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
}}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="none"
|
||||
onEmaIdpLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Not signed in/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Sign out" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows expired state with sign out", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
}}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="expired"
|
||||
onEmaIdpLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Session expired")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Sign out" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js";
|
||||
import type { ClientSettingsFormValues } from "./clientSettingsValues.js";
|
||||
|
||||
export type ClientSettingsSection = "ema";
|
||||
|
||||
export interface ClientSettingsFormProps {
|
||||
settings: ClientSettingsFormValues;
|
||||
expandedSections: ClientSettingsSection[];
|
||||
onExpandedSectionsChange: (sections: ClientSettingsSection[]) => void;
|
||||
onSettingsChange: (settings: ClientSettingsFormValues) => void;
|
||||
emaIdpLoginState?: EmaIdpLoginState;
|
||||
onEmaIdpLogout?: () => void;
|
||||
}
|
||||
|
||||
const HintText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
});
|
||||
|
||||
export function ClientSettingsForm({
|
||||
settings,
|
||||
expandedSections,
|
||||
onExpandedSectionsChange,
|
||||
onSettingsChange,
|
||||
emaIdpLoginState = "none",
|
||||
onEmaIdpLogout,
|
||||
}: ClientSettingsFormProps) {
|
||||
function patch(partial: Partial<ClientSettingsFormValues>) {
|
||||
onSettingsChange({ ...settings, ...partial });
|
||||
}
|
||||
|
||||
const showIdpSession =
|
||||
settings.emaEnabled &&
|
||||
settings.issuer.trim() !== "" &&
|
||||
emaIdpLoginState !== "none";
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
multiple
|
||||
value={expandedSections}
|
||||
onChange={(value) =>
|
||||
onExpandedSectionsChange(value as ClientSettingsSection[])
|
||||
}
|
||||
variant="separated"
|
||||
>
|
||||
<Accordion.Item value="ema">
|
||||
<Accordion.Control>Enterprise-Managed Authorization</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md">
|
||||
<Checkbox
|
||||
label="Enable enterprise IdP configuration"
|
||||
description="Enterprise-managed authorization signs you in through your organization's IdP instead of each MCP server's own OAuth login. Enable enterprise-managed authorization on participating MCP server's settings."
|
||||
checked={settings.emaEnabled}
|
||||
onChange={(e) => patch({ emaEnabled: e.currentTarget.checked })}
|
||||
/>
|
||||
{settings.emaEnabled && (
|
||||
<>
|
||||
<HintText>
|
||||
Register this redirect URI with your IdP:{" "}
|
||||
{typeof window !== "undefined"
|
||||
? `${window.location.origin}/oauth/callback`
|
||||
: "http://localhost:6274/oauth/callback"}
|
||||
</HintText>
|
||||
<TextInput
|
||||
label="Issuer"
|
||||
description="Your enterprise IdP issuer URL."
|
||||
value={settings.issuer}
|
||||
onChange={(e) => patch({ issuer: e.currentTarget.value })}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.issuer ? (
|
||||
<ClearButton onClick={() => patch({ issuer: "" })} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Client ID"
|
||||
description="Client id registered with your enterprise IdP."
|
||||
value={settings.clientId}
|
||||
onChange={(e) => patch({ clientId: e.currentTarget.value })}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.clientId ? (
|
||||
<ClearButton onClick={() => patch({ clientId: "" })} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Client Secret"
|
||||
description="Client secret for your enterprise IdP (if required)."
|
||||
type="password"
|
||||
value={settings.clientSecret}
|
||||
onChange={(e) =>
|
||||
patch({ clientSecret: e.currentTarget.value })
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.clientSecret ? (
|
||||
<ClearButton
|
||||
onClick={() => patch({ clientSecret: "" })}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{settings.issuer.trim() !== "" && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Stack gap={4} flex={1}>
|
||||
<Text size="sm" fw={500}>
|
||||
IdP sign-in
|
||||
</Text>
|
||||
{emaIdpLoginState === "logged_in" ? (
|
||||
<>
|
||||
<Badge w="fit-content" color="green" variant="light">
|
||||
Signed in
|
||||
</Badge>
|
||||
<HintText>
|
||||
Your enterprise IdP session is active. Connecting to
|
||||
EMA-enabled MCP servers will not prompt for IdP
|
||||
login until you sign out or the session expires.
|
||||
</HintText>
|
||||
</>
|
||||
) : emaIdpLoginState === "expired" ? (
|
||||
<>
|
||||
<Badge w="fit-content" color="yellow" variant="light">
|
||||
Session expired
|
||||
</Badge>
|
||||
<HintText>
|
||||
Your cached IdP session has expired. The next
|
||||
connect to an EMA-enabled server will prompt for IdP
|
||||
login.
|
||||
</HintText>
|
||||
</>
|
||||
) : (
|
||||
<HintText>
|
||||
Not signed in to your enterprise IdP. Connecting to an
|
||||
EMA-enabled MCP server will open IdP login.
|
||||
</HintText>
|
||||
)}
|
||||
</Stack>
|
||||
{showIdpSession && onEmaIdpLogout ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
onClick={onEmaIdpLogout}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
canPersistClientSettingsDraft,
|
||||
clientConfigToFormValues,
|
||||
formValuesToClientConfig,
|
||||
} from "./clientSettingsValues";
|
||||
|
||||
describe("clientSettingsValues", () => {
|
||||
it("clientConfigToFormValues maps enterpriseManagedAuth.idp", () => {
|
||||
expect(
|
||||
clientConfigToFormValues({
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
});
|
||||
});
|
||||
|
||||
it("clientConfigToFormValues preserves IdP fields when EMA is disabled", () => {
|
||||
expect(
|
||||
clientConfigToFormValues({
|
||||
enterpriseManagedAuth: {
|
||||
enabled: false,
|
||||
idp: {
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
emaEnabled: false,
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
});
|
||||
});
|
||||
|
||||
it("formValuesToClientConfig keeps IdP credentials when disabled", () => {
|
||||
expect(
|
||||
formValuesToClientConfig({
|
||||
emaEnabled: false,
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
}),
|
||||
).toEqual({
|
||||
enterpriseManagedAuth: {
|
||||
enabled: false,
|
||||
idp: {
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("formValuesToClientConfig omits block when disabled with no stored fields", () => {
|
||||
expect(
|
||||
formValuesToClientConfig({
|
||||
emaEnabled: false,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("formValuesToClientConfig trims issuer and clientId when enabled", () => {
|
||||
expect(
|
||||
formValuesToClientConfig({
|
||||
emaEnabled: true,
|
||||
issuer: " https://idp.test ",
|
||||
clientId: " cid ",
|
||||
clientSecret: "sec",
|
||||
}),
|
||||
).toEqual({
|
||||
enterpriseManagedAuth: {
|
||||
enabled: true,
|
||||
idp: {
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("canPersistClientSettingsDraft allows disabled or complete IdP fields", () => {
|
||||
expect(
|
||||
canPersistClientSettingsDraft({
|
||||
emaEnabled: false,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canPersistClientSettingsDraft({
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canPersistClientSettingsDraft({
|
||||
emaEnabled: true,
|
||||
issuer: "",
|
||||
clientId: "cid",
|
||||
clientSecret: "",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||
|
||||
/** Form shape for install-level client settings (client.json). */
|
||||
export interface ClientSettingsFormValues {
|
||||
emaEnabled: boolean;
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export const EMPTY_CLIENT_SETTINGS: ClientSettingsFormValues = {
|
||||
emaEnabled: false,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
};
|
||||
|
||||
export function clientConfigToFormValues(
|
||||
config: ClientConfig,
|
||||
): ClientSettingsFormValues {
|
||||
const ema = config.enterpriseManagedAuth;
|
||||
const idp = ema?.idp;
|
||||
if (!idp) {
|
||||
return { ...EMPTY_CLIENT_SETTINGS };
|
||||
}
|
||||
return {
|
||||
emaEnabled: ema.enabled !== false,
|
||||
issuer: idp.issuer,
|
||||
clientId: idp.clientId,
|
||||
clientSecret: idp.clientSecret ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function hasStoredIdpFields(values: ClientSettingsFormValues): boolean {
|
||||
return (
|
||||
values.issuer.trim() !== "" ||
|
||||
values.clientId.trim() !== "" ||
|
||||
values.clientSecret !== ""
|
||||
);
|
||||
}
|
||||
|
||||
export function formValuesToClientConfig(
|
||||
values: ClientSettingsFormValues,
|
||||
): ClientConfig {
|
||||
if (!hasStoredIdpFields(values)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const idp = {
|
||||
issuer: values.issuer.trim(),
|
||||
clientId: values.clientId.trim(),
|
||||
clientSecret: values.clientSecret,
|
||||
};
|
||||
|
||||
return {
|
||||
enterpriseManagedAuth: {
|
||||
enabled: values.emaEnabled,
|
||||
idp,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Skip debounced persist while EMA is enabled but required IdP fields are blank. */
|
||||
export function canPersistClientSettingsDraft(
|
||||
values: ClientSettingsFormValues,
|
||||
): boolean {
|
||||
if (!values.emaEnabled) return true;
|
||||
return values.issuer.trim() !== "" && values.clientId.trim() !== "";
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { AppShell } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import {
|
||||
ClientSettingsModal,
|
||||
type ClientSettingsModalProps,
|
||||
} from "./ClientSettingsModal";
|
||||
import type { ClientSettingsFormValues } from "../ClientSettingsForm/clientSettingsValues";
|
||||
|
||||
const configuredSettings: ClientSettingsFormValues = {
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "inspector-idp-client",
|
||||
clientSecret: "super-secret-idp-value",
|
||||
};
|
||||
|
||||
function InteractiveRender(args: ClientSettingsModalProps) {
|
||||
const [, updateArgs] = useArgs<ClientSettingsModalProps>();
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell.Main>
|
||||
<ClientSettingsModal
|
||||
{...args}
|
||||
onSettingsChange={(settings) => {
|
||||
args.onSettingsChange(settings);
|
||||
updateArgs({ settings });
|
||||
}}
|
||||
/>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof ClientSettingsModal> = {
|
||||
title: "Groups/ClientSettingsModal",
|
||||
component: ClientSettingsModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: InteractiveRender,
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: fn(),
|
||||
onSettingsChange: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ClientSettingsModal>;
|
||||
|
||||
export const Configured: Story = {
|
||||
args: {
|
||||
settings: configuredSettings,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
settings: {
|
||||
emaEnabled: false,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EnabledEmptyFields: Story = {
|
||||
args: {
|
||||
settings: {
|
||||
emaEnabled: true,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { ClientSettingsModal } from "./ClientSettingsModal";
|
||||
import { EMPTY_CLIENT_SETTINGS } from "../ClientSettingsForm/clientSettingsValues";
|
||||
|
||||
describe("ClientSettingsModal", () => {
|
||||
it("renders the title when opened", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Client Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onClose when the CloseButton is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={onClose}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
document.querySelector("button.mantine-CloseButton-root")!,
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when toggling EMA enable", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Enable enterprise IdP configuration",
|
||||
}),
|
||||
);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows IdP fields when EMA is enabled", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Issuer")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Client ID")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Client Secret")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when typing issuer", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.type(screen.getByLabelText("Issuer"), "h");
|
||||
expect(onSettingsChange).toHaveBeenCalled();
|
||||
const call =
|
||||
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
|
||||
expect(call.issuer).toBe("h");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { useState } from "react";
|
||||
import { CloseButton, Group, Modal, Stack, Title } from "@mantine/core";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import {
|
||||
ClientSettingsForm,
|
||||
type ClientSettingsSection,
|
||||
} from "../ClientSettingsForm/ClientSettingsForm";
|
||||
import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js";
|
||||
import type { ClientSettingsFormValues } from "../ClientSettingsForm/clientSettingsValues.js";
|
||||
|
||||
const ALL_SECTIONS: ClientSettingsSection[] = ["ema"];
|
||||
|
||||
export interface ClientSettingsModalProps {
|
||||
opened: boolean;
|
||||
settings: ClientSettingsFormValues;
|
||||
onClose: () => void;
|
||||
onSettingsChange: (settings: ClientSettingsFormValues) => void;
|
||||
emaIdpLoginState?: EmaIdpLoginState;
|
||||
onEmaIdpLogout?: () => void;
|
||||
}
|
||||
|
||||
export function ClientSettingsModal({
|
||||
opened,
|
||||
settings,
|
||||
onClose,
|
||||
onSettingsChange,
|
||||
emaIdpLoginState,
|
||||
onEmaIdpLogout,
|
||||
}: ClientSettingsModalProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<
|
||||
ClientSettingsSection[]
|
||||
>(["ema"]);
|
||||
|
||||
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
|
||||
|
||||
function handleToggleAll() {
|
||||
setExpandedSections(allExpanded ? [] : ALL_SECTIONS);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<ListToggle
|
||||
compact={!allExpanded}
|
||||
variant="subtle"
|
||||
onToggle={handleToggleAll}
|
||||
/>
|
||||
<Title order={4} ta="center" flex={1}>
|
||||
Client Settings
|
||||
</Title>
|
||||
<CloseButton onClick={onClose} />
|
||||
</Group>
|
||||
<ClientSettingsForm
|
||||
settings={settings}
|
||||
expandedSections={expandedSections}
|
||||
onExpandedSectionsChange={setExpandedSections}
|
||||
onSettingsChange={onSettingsChange}
|
||||
emaIdpLoginState={emaIdpLoginState}
|
||||
onEmaIdpLogout={onEmaIdpLogout}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -84,6 +84,8 @@ export const WithOAuth: Story = {
|
|||
},
|
||||
transport: "streamable-http",
|
||||
oauth: {
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
authUrl: "https://auth.example.com/oauth2/authorize",
|
||||
scopes: ["read", "write", "admin"],
|
||||
accessToken: "eyJhbGciOiJSUzI1NiIs...truncated",
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ describe("ConnectionInfoContent", () => {
|
|||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
oauth={{
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
clientId: "client-abc",
|
||||
authUrl: "https://auth.example.com/authorize",
|
||||
scopes: ["read", "write"],
|
||||
accessToken: "token-123",
|
||||
|
|
@ -109,6 +112,9 @@ describe("ConnectionInfoContent", () => {
|
|||
/>,
|
||||
);
|
||||
expect(screen.getByText("OAuth Details")).toBeInTheDocument();
|
||||
expect(screen.getByText("Standard")).toBeInTheDocument();
|
||||
expect(screen.getByText("Authorized")).toBeInTheDocument();
|
||||
expect(screen.getByText("client-abc")).toBeInTheDocument();
|
||||
expect(screen.getByText("Auth URL")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("https://auth.example.com/authorize"),
|
||||
|
|
@ -117,16 +123,35 @@ describe("ConnectionInfoContent", () => {
|
|||
expect(screen.getByText("token-123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides OAuth fields that are not provided", () => {
|
||||
it("renders EMA idp session when provided", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
oauth={{
|
||||
protocol: "ema",
|
||||
authorized: true,
|
||||
idpSession: "logged_in",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Enterprise-managed")).toBeInTheDocument();
|
||||
expect(screen.getByText("IdP session")).toBeInTheDocument();
|
||||
expect(screen.getByText("Signed in")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides optional OAuth fields that are not provided", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="stdio"
|
||||
oauth={{ scopes: [] }}
|
||||
oauth={{ protocol: "standard", authorized: false }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("OAuth Details")).toBeInTheDocument();
|
||||
expect(screen.getByText("Not authorized")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Auth URL")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Scopes")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -17,11 +17,17 @@ import {
|
|||
type CapabilityKey,
|
||||
} from "../../elements/CapabilityItem/CapabilityItem";
|
||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
|
||||
|
||||
export interface OAuthDetails {
|
||||
protocol: "standard" | "ema";
|
||||
authorized: boolean;
|
||||
clientId?: string;
|
||||
authUrl?: string;
|
||||
scopes?: string[];
|
||||
accessToken?: string;
|
||||
/** EMA only — install-level IdP session for legs 1–2. */
|
||||
idpSession?: "none" | "logged_in" | "expired";
|
||||
}
|
||||
|
||||
export interface ConnectionInfoContentProps {
|
||||
|
|
@ -45,6 +51,23 @@ function formatScopes(scopes: string[]): string {
|
|||
return scopes.join(", ");
|
||||
}
|
||||
|
||||
function formatProtocol(protocol: OAuthDetails["protocol"]): string {
|
||||
return protocol === "ema" ? "Enterprise-managed" : "Standard";
|
||||
}
|
||||
|
||||
function formatIdpSession(
|
||||
session: NonNullable<OAuthDetails["idpSession"]>,
|
||||
): string {
|
||||
switch (session) {
|
||||
case "logged_in":
|
||||
return "Signed in";
|
||||
case "expired":
|
||||
return "Session expired";
|
||||
default:
|
||||
return "Not signed in";
|
||||
}
|
||||
}
|
||||
|
||||
const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [
|
||||
"tools",
|
||||
"resources",
|
||||
|
|
@ -148,6 +171,30 @@ export function ConnectionInfoContent({
|
|||
<Stack gap="xs">
|
||||
<SectionHeading>OAuth Details</SectionHeading>
|
||||
<Stack gap="xs">
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm">Protocol</Text>
|
||||
<ValueText>{formatProtocol(oauth.protocol)}</ValueText>
|
||||
|
||||
<Text size="sm">Status</Text>
|
||||
<Badge
|
||||
variant="outline"
|
||||
color={oauth.authorized ? "green" : "gray"}
|
||||
>
|
||||
{oauth.authorized ? "Authorized" : "Not authorized"}
|
||||
</Badge>
|
||||
</SimpleGrid>
|
||||
{oauth.clientId && (
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm">Client ID</Text>
|
||||
<Code>{oauth.clientId}</Code>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{oauth.protocol === "ema" && oauth.idpSession && (
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm">IdP session</Text>
|
||||
<ValueText>{formatIdpSession(oauth.idpSession)}</ValueText>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{oauth.authUrl && (
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm">Auth URL</Text>
|
||||
|
|
@ -161,10 +208,7 @@ export function ConnectionInfoContent({
|
|||
</SimpleGrid>
|
||||
)}
|
||||
{oauth.accessToken && (
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm">Access Token</Text>
|
||||
<Code>{oauth.accessToken}</Code>
|
||||
</SimpleGrid>
|
||||
<OAuthAccessTokenField accessToken={oauth.accessToken} />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
|
||||
|
||||
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
|
||||
|
||||
describe("OAuthAccessTokenField", () => {
|
||||
it("renders token with copy control beside the label", () => {
|
||||
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
|
||||
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||
expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument();
|
||||
expect(screen.getByText("eyJzdWIiOiJ1c2VyIn0")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces raw token with decoded JSON and restores on toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
|
||||
|
||||
expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
|
||||
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("eyJhbGciOiJub25lIn0")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show token" }));
|
||||
expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits decode toggle for opaque tokens", () => {
|
||||
renderWithMantine(
|
||||
<OAuthAccessTokenField accessToken="opaque-access-token-value" />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Decode JWT" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { Fragment, useMemo, useState } from "react";
|
||||
import { Button, Code, Flex, Stack, Text } from "@mantine/core";
|
||||
import { decodeJwtPayload, isJwtFormat } from "@inspector/core/auth/ema/jwt.js";
|
||||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||
|
||||
export interface OAuthAccessTokenFieldProps {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
const DecodeButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "compact-xs",
|
||||
});
|
||||
|
||||
/** Wrap JWT at segment boundaries; break long segments without orphaning `.`. */
|
||||
function JwtTokenText({ token }: { token: string }) {
|
||||
const parts = token.split(".");
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 && "."}
|
||||
<span style={{ wordBreak: "break-all" }}>{part}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function OAuthAccessTokenField({
|
||||
accessToken,
|
||||
}: OAuthAccessTokenFieldProps) {
|
||||
const [showDecoded, setShowDecoded] = useState(false);
|
||||
const isJwt = isJwtFormat(accessToken);
|
||||
const jwtDecoded = useMemo(
|
||||
() => (isJwt ? decodeJwtPayload(accessToken) : undefined),
|
||||
[accessToken, isJwt],
|
||||
);
|
||||
|
||||
const decodedText = useMemo(() => {
|
||||
if (!jwtDecoded) return undefined;
|
||||
return JSON.stringify(
|
||||
{ header: jwtDecoded.header, payload: jwtDecoded.payload },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}, [jwtDecoded]);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Flex justify="space-between" align="center" gap="sm" wrap="nowrap">
|
||||
<Text size="sm">Access Token</Text>
|
||||
<Flex gap={4} align="center" wrap="nowrap">
|
||||
{jwtDecoded && (
|
||||
<DecodeButton
|
||||
onClick={() => setShowDecoded((open) => !open)}
|
||||
aria-pressed={showDecoded}
|
||||
>
|
||||
{showDecoded ? "Show token" : "Decode JWT"}
|
||||
</DecodeButton>
|
||||
)}
|
||||
<CopyButton value={accessToken} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Code block p="sm" variant="wrapping">
|
||||
{showDecoded && decodedText ? (
|
||||
decodedText
|
||||
) : isJwt ? (
|
||||
<JwtTokenText token={accessToken} />
|
||||
) : (
|
||||
accessToken
|
||||
)}
|
||||
</Code>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
|
||||
import { oauthDetailsFromConnectionState } from "./oauthDetailsFromConnectionState";
|
||||
|
||||
describe("oauthDetailsFromConnectionState", () => {
|
||||
it("maps authorized standard connection state", () => {
|
||||
const state: OAuthConnectionState = {
|
||||
authorized: true,
|
||||
protocol: "standard",
|
||||
serverUrl: "https://mcp.example.com/mcp",
|
||||
grantedScope: "read write",
|
||||
tokens: { access_token: "tok", token_type: "Bearer" },
|
||||
client: {
|
||||
source: "preregistered",
|
||||
clientId: "client-1",
|
||||
hasClientSecret: true,
|
||||
},
|
||||
authorizationServerMetadata: {
|
||||
issuer: "https://auth.example.com",
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
};
|
||||
|
||||
expect(oauthDetailsFromConnectionState(state)).toEqual({
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
clientId: "client-1",
|
||||
authUrl: "https://auth.example.com/authorize",
|
||||
scopes: ["read", "write"],
|
||||
accessToken: "tok",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes EMA idp session when present", () => {
|
||||
const state: OAuthConnectionState = {
|
||||
authorized: false,
|
||||
protocol: "ema",
|
||||
serverUrl: "https://mcp.example.com/mcp",
|
||||
ema: {
|
||||
idpIssuer: "https://idp.example.com",
|
||||
idpClientId: "idp-client",
|
||||
idpSession: "logged_in",
|
||||
},
|
||||
};
|
||||
|
||||
expect(oauthDetailsFromConnectionState(state)).toEqual({
|
||||
protocol: "ema",
|
||||
authorized: false,
|
||||
idpSession: "logged_in",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
|
||||
import type { OAuthDetails } from "./ConnectionInfoContent";
|
||||
|
||||
/** Map persisted connection auth state into Connection Info display props. */
|
||||
export function oauthDetailsFromConnectionState(
|
||||
state: OAuthConnectionState,
|
||||
): OAuthDetails {
|
||||
const scopeSource = state.grantedScope ?? state.configuredScope;
|
||||
const scopes = scopeSource?.split(" ").filter(Boolean);
|
||||
|
||||
return {
|
||||
protocol: state.protocol,
|
||||
authorized: state.authorized,
|
||||
...(state.client?.clientId && { clientId: state.client.clientId }),
|
||||
...(state.authorizationServerMetadata?.authorization_endpoint && {
|
||||
authUrl: state.authorizationServerMetadata.authorization_endpoint,
|
||||
}),
|
||||
...(scopes && scopes.length > 0 && { scopes }),
|
||||
...(state.tokens?.access_token && {
|
||||
accessToken: state.tokens.access_token,
|
||||
}),
|
||||
...(state.protocol === "ema" &&
|
||||
state.ema?.idpSession && { idpSession: state.ema.idpSession }),
|
||||
};
|
||||
}
|
||||
|
|
@ -77,6 +77,8 @@ export const WithOAuth: Story = {
|
|||
clientCapabilities,
|
||||
transport: "streamable-http",
|
||||
oauth: {
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
authUrl: "https://auth.example.com/oauth2/authorize",
|
||||
scopes: ["read", "write"],
|
||||
accessToken: "eyJhbGciOiJSUzI1NiIs...truncated",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ describe("ConnectionInfoModal", () => {
|
|||
clientCapabilities={clientCapabilities}
|
||||
transport="streamable-http"
|
||||
oauth={{
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
authUrl: "https://auth.example.com/authorize",
|
||||
scopes: ["read", "write"],
|
||||
accessToken: "token-abc",
|
||||
|
|
|
|||
|
|
@ -492,9 +492,47 @@ describe("ServerSettingsForm", () => {
|
|||
clientId: "a",
|
||||
clientSecret: "",
|
||||
scopes: "",
|
||||
enterpriseManaged: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes onOAuthChange when enterprise-managed is toggled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOAuthChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onOAuthChange={onOAuthChange}
|
||||
settings={emptySettings}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: /Enterprise-managed authorization/i,
|
||||
}),
|
||||
);
|
||||
expect(onOAuthChange).toHaveBeenCalledWith({
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
scopes: "",
|
||||
enterpriseManaged: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the OAuth Settings section for stdio servers", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={emptySettings}
|
||||
serverType="stdio"
|
||||
expandedSections={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("OAuth Settings")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Client ID")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onOAuthChange when typing in scopes (uses existing oauth values)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOAuthChange = vi.fn();
|
||||
|
|
@ -531,6 +569,7 @@ describe("ServerSettingsForm", () => {
|
|||
clientId: "",
|
||||
clientSecret: "z",
|
||||
scopes: "",
|
||||
enterpriseManaged: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
|||
import type {
|
||||
InspectorServerSettings,
|
||||
OAuthSettings,
|
||||
ServerType,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
|
||||
import type { Root } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
export type ServerSettingsSection =
|
||||
|
|
@ -27,6 +29,8 @@ export type ServerSettingsSection =
|
|||
|
||||
export interface ServerSettingsFormProps {
|
||||
settings: InspectorServerSettings;
|
||||
/** Transport type — EMA checkbox is hidden for stdio. Defaults to streamable-http. */
|
||||
serverType?: ServerType;
|
||||
/**
|
||||
* Whether the target server uses the stdio transport. Gates the Working
|
||||
* Directory field and Environment Variables section in the Options area —
|
||||
|
|
@ -176,6 +180,7 @@ function RootRows({
|
|||
|
||||
export function ServerSettingsForm({
|
||||
settings,
|
||||
serverType = "streamable-http",
|
||||
isStdio,
|
||||
expandedSections,
|
||||
onExpandedSectionsChange,
|
||||
|
|
@ -197,6 +202,7 @@ export function ServerSettingsForm({
|
|||
onRemoveRoot,
|
||||
onRootChange,
|
||||
}: ServerSettingsFormProps) {
|
||||
const oauthCapable = isOAuthCapableServerType(serverType);
|
||||
const handleMaxFetchRequestsChange = (value: number | string) => {
|
||||
if (typeof value === "number") {
|
||||
onMaxFetchRequestsChange(value);
|
||||
|
|
@ -225,6 +231,7 @@ export function ServerSettingsForm({
|
|||
clientId: settings.oauthClientId ?? "",
|
||||
clientSecret: settings.oauthClientSecret ?? "",
|
||||
scopes: settings.oauthScopes ?? "",
|
||||
enterpriseManaged: settings.enterpriseManaged ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -400,78 +407,91 @@ export function ServerSettingsForm({
|
|||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="oauth">
|
||||
<Accordion.Control>OAuth Settings</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md">
|
||||
<HintText>
|
||||
Pre-configure OAuth credentials for servers requiring
|
||||
authentication
|
||||
</HintText>
|
||||
<TextInput
|
||||
label="Client ID"
|
||||
value={settings.oauthClientId ?? ""}
|
||||
onChange={(e) =>
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
clientId: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.oauthClientId ? (
|
||||
<ClearButton
|
||||
onClick={() =>
|
||||
onOAuthChange({ ...currentOAuth(), clientId: "" })
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Client Secret"
|
||||
value={settings.oauthClientSecret ?? ""}
|
||||
type="password"
|
||||
onChange={(e) =>
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
clientSecret: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.oauthClientSecret ? (
|
||||
<ClearButton
|
||||
onClick={() =>
|
||||
onOAuthChange({ ...currentOAuth(), clientSecret: "" })
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Scopes"
|
||||
value={settings.oauthScopes ?? ""}
|
||||
onChange={(e) =>
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
scopes: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.oauthScopes ? (
|
||||
<ClearButton
|
||||
onClick={() =>
|
||||
onOAuthChange({ ...currentOAuth(), scopes: "" })
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
{oauthCapable ? (
|
||||
<Accordion.Item value="oauth">
|
||||
<Accordion.Control>OAuth Settings</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md">
|
||||
<Checkbox
|
||||
label="Enterprise-managed authorization"
|
||||
description="Connect via the configured enterprise IdP instead of interactive OAuth to the MCP authorization server. OAuth fields below are resource authorization server credentials."
|
||||
checked={settings.enterpriseManaged ?? false}
|
||||
onChange={(e) =>
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
enterpriseManaged: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<HintText>
|
||||
Pre-configure OAuth credentials for servers requiring
|
||||
authentication
|
||||
</HintText>
|
||||
<TextInput
|
||||
label="Client ID"
|
||||
value={settings.oauthClientId ?? ""}
|
||||
onChange={(e) =>
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
clientId: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.oauthClientId ? (
|
||||
<ClearButton
|
||||
onClick={() =>
|
||||
onOAuthChange({ ...currentOAuth(), clientId: "" })
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Client Secret"
|
||||
value={settings.oauthClientSecret ?? ""}
|
||||
type="password"
|
||||
onChange={(e) =>
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
clientSecret: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.oauthClientSecret ? (
|
||||
<ClearButton
|
||||
onClick={() =>
|
||||
onOAuthChange({ ...currentOAuth(), clientSecret: "" })
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Scopes"
|
||||
value={settings.oauthScopes ?? ""}
|
||||
onChange={(e) =>
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
scopes: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.oauthScopes ? (
|
||||
<ClearButton
|
||||
onClick={() =>
|
||||
onOAuthChange({ ...currentOAuth(), scopes: "" })
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
) : null}
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const meta: Meta<typeof ServerSettingsModal> = {
|
|||
render: InteractiveRender,
|
||||
args: {
|
||||
opened: true,
|
||||
serverType: "streamable-http",
|
||||
isStdio: false,
|
||||
onClose: fn(),
|
||||
onSettingsChange: fn(),
|
||||
|
|
@ -87,6 +88,7 @@ export const EmptySettings: Story = {
|
|||
// Environment Variables section.
|
||||
export const StdioServer: Story = {
|
||||
args: {
|
||||
serverType: "stdio",
|
||||
isStdio: true,
|
||||
settings: {
|
||||
headers: [],
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened={false}
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
|
|
@ -48,6 +49,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
|
|
@ -57,6 +59,20 @@ describe("ServerSettingsModal", () => {
|
|||
expect(screen.getByText("Custom Headers")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the OAuth Settings section for stdio servers", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="stdio"
|
||||
isStdio
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("OAuth Settings")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onClose when the CloseButton is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
|
|
@ -64,6 +80,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={onClose}
|
||||
onSettingsChange={vi.fn()}
|
||||
|
|
@ -85,6 +102,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -105,6 +123,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={initialSettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -126,6 +145,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={initialSettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -148,6 +168,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -168,6 +189,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={initialSettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -191,6 +213,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={initialSettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -212,6 +235,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -232,6 +256,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -245,6 +270,34 @@ describe("ServerSettingsModal", () => {
|
|||
expect(call.oauthClientId).toBe("a");
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when toggling enterprise-managed authorization", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "OAuth Settings" }));
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Enterprise-managed authorization",
|
||||
}),
|
||||
);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||
...emptySettings,
|
||||
oauthClientId: "",
|
||||
oauthClientSecret: "",
|
||||
oauthScopes: "",
|
||||
enterpriseManaged: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onSettingsChange with a blank row when adding a root", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
|
|
@ -252,6 +305,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -272,6 +326,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={initialSettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -294,6 +349,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={initialSettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -315,6 +371,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
|
|
@ -359,6 +416,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
|
|
@ -376,6 +434,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="stdio"
|
||||
isStdio
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -401,6 +460,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={{ ...emptySettings, env: [{ key: "A", value: "1" }] }}
|
||||
serverType="stdio"
|
||||
isStdio
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -424,6 +484,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={{ ...emptySettings, env: [{ key: "A", value: "1" }] }}
|
||||
serverType="stdio"
|
||||
isStdio
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
@ -444,6 +505,7 @@ describe("ServerSettingsModal", () => {
|
|||
<ServerSettingsModal
|
||||
opened
|
||||
settings={{ ...emptySettings, cwd: "/srv" }}
|
||||
serverType="stdio"
|
||||
isStdio
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue