feat(web,core): enterprise-managed authorization (EMA) (#1509) (#1512)

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:
Bob Dickinson 2026-06-24 19:15:12 -07:00 • committed by GitHub
commit 5de4e911e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 7585 additions and 595 deletions

View file

@ -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,
});
});

View file

@ -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>
);
}