Merge branch 'v2/main' into v2/fix/1909-salvage-malformed-list-items

This commit is contained in:
Cliff Hall 2026-08-15 23:05:31 -04:00 • committed by GitHub
commit 0f31de83af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1782 additions and 138 deletions

View file

@ -3,7 +3,7 @@ import type {
InitializeResult,
} from "@modelcontextprotocol/client";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { expect, userEvent, within } from "storybook/test";
import {
ConnectionInfoContent,
SERVER_INFO_NOT_REPORTED_LABEL,
@ -161,3 +161,37 @@ export const WithOAuth: Story = {
},
},
};
// An authorization server that also returned an `id_token` (#2019). Shown for
// inspection only — decoding it is display-only, and it is never used as a
// credential.
export const WithOAuthIdToken: Story = {
args: {
initializeResult: {
protocolVersion: "2025-03-26",
serverInfo: { name: "OIDC-fronted Server", version: "3.0.0" },
capabilities: { tools: { listChanged: true } },
},
clientCapabilities: { roots: { listChanged: true } },
transport: "streamable-http",
oauth: {
protocol: "standard",
authorized: true,
authUrl: "https://auth.example.com/oauth2/authorize",
scopes: ["openid", "email", "read"],
accessToken: "opaque-access-token",
// header {"alg":"none"} / payload {"sub":"user-42","email":"a@b.test"}
idToken:
"eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTQyIiwiZW1haWwiOiJhQGIudGVzdCJ9.",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("ID Token")).toBeInTheDocument();
// Only the ID token is a JWT here, so exactly one decode toggle is offered.
await userEvent.click(
canvas.getByRole("button", { name: "Decode JWT for ID Token" }),
);
expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument();
},
};

View file

@ -473,6 +473,95 @@ describe("ConnectionInfoContent", () => {
expect(screen.queryByText("Auth URL")).not.toBeInTheDocument();
expect(screen.queryByText("Scopes")).not.toBeInTheDocument();
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
expect(screen.queryByText("ID Token")).not.toBeInTheDocument();
});
it("renders the ID Token row when the token set carries one", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
protocol: "standard",
authorized: true,
accessToken: "token-123",
idToken: "id-token-456",
}}
/>,
);
expect(screen.getByText("Access Token")).toBeInTheDocument();
expect(screen.getByText("ID Token")).toBeInTheDocument();
expect(screen.getByText("id-token-456")).toBeInTheDocument();
});
it("gives the two token rows' controls distinct accessible names", () => {
// Both rows carry a copy control, and both tokens here are JWTs so both
// carry a decode toggle. Screen-reader button navigation has only the
// accessible name to go on, so the four must not collide (#2019 review).
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
protocol: "standard",
authorized: true,
accessToken: jwt,
idToken: jwt,
}}
/>,
);
const names = screen
.getAllByRole("button")
.map((button) => button.getAttribute("aria-label") ?? button.textContent)
.filter((name): name is string => Boolean(name));
expect(new Set(names).size).toBe(names.length);
for (const name of [
"Copy Access Token",
"Copy ID Token",
"Decode JWT for Access Token",
"Decode JWT for ID Token",
]) {
expect(screen.getByRole("button", { name })).toBeInTheDocument();
}
});
it("renders the ID Token row on its own when there is no access token", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
protocol: "standard",
authorized: true,
idToken: "id-token-456",
}}
/>,
);
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
expect(screen.getByText("ID Token")).toBeInTheDocument();
});
it("omits the ID Token row when only an access token is present", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
protocol: "standard",
authorized: true,
accessToken: "token-123",
}}
/>,
);
expect(screen.getByText("Access Token")).toBeInTheDocument();
expect(screen.queryByText("ID Token")).not.toBeInTheDocument();
});
it("does not render OAuth section when oauth prop is omitted", () => {

View file

@ -24,7 +24,7 @@ import {
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { EraBadge } from "../../elements/EraBadge/EraBadge";
import { isModernEra } from "../../elements/EraBadge/eraUtils";
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
import { OAuthTokenField } from "./OAuthTokenField";
export interface OAuthDetails {
protocol: "standard" | "ema";
@ -34,6 +34,13 @@ export interface OAuthDetails {
authUrl?: string;
scopes?: string[];
accessToken?: string;
/**
* OIDC `id_token` from the stored token set, when the authorization server
* returned one. Shown for inspection only — the MCP authorization spec is
* plain OAuth 2.1 and the Inspector never treats this as a credential
* (#2019). Absent when the token set carries none, so no empty row renders.
*/
idToken?: string;
/** EMA only — install-level IdP session for legs 1–2. */
idpSession?: "none" | "logged_in" | "expired";
}
@ -370,20 +377,27 @@ export function ConnectionInfoContent({
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
</SimpleGrid>
)}
{oauth.accessToken ? (
<OAuthAccessTokenField
accessToken={oauth.accessToken}
{oauth.accessToken && (
<OAuthTokenField
label="Access Token"
token={oauth.accessToken}
onClear={onClearOAuth}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>
) : (
onClearOAuth && (
<Flex justify="flex-end">
<ClearOAuthButton onClick={onClearOAuth}>
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
</ClearOAuthButton>
</Flex>
)
)}
{/* Viewer only — an `id_token` the AS happened to return, decoded
on request. It carries no clear action: clearing OAuth state is
one action for the whole token set, owned by the access-token
row (or the standalone button below when there is none). */}
{oauth.idToken && (
<OAuthTokenField label="ID Token" token={oauth.idToken} />
)}
{!oauth.accessToken && onClearOAuth && (
<Flex justify="flex-end">
<ClearOAuthButton onClick={onClearOAuth}>
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
</ClearOAuthButton>
</Flex>
)}
</Stack>
</Stack>

View file

@ -1,90 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL } from "./ConnectionInfoContent";
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
describe("OAuthAccessTokenField", () => {
it("renders token with copy control beside the content", () => {
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();
});
it("copies decoded JSON while decode view is shown", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
await user.click(screen.getByRole("button", { name: "Copy" }));
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('"sub": "user"'),
);
expect(writeText).not.toHaveBeenCalledWith(jwt);
});
it("copies the raw token while token view is shown", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
await user.click(screen.getByRole("button", { name: "Copy" }));
expect(writeText).toHaveBeenCalledWith(jwt);
});
it("renders clear action on the access token header row", async () => {
const user = userEvent.setup();
const onClear = vi.fn();
renderWithMantine(
<OAuthAccessTokenField
accessToken="opaque-access-token-value"
onClear={onClear}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>,
);
await user.click(
screen.getByRole("button", {
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
}),
);
expect(onClear).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,139 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL } from "./ConnectionInfoContent";
import { OAuthTokenField } from "./OAuthTokenField";
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
describe("OAuthTokenField", () => {
it("renders token with copy control beside the content", () => {
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
expect(screen.getByText("Access Token")).toBeInTheDocument();
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Copy Access Token" }),
).toBeInTheDocument();
});
it("replaces raw token with decoded JSON and restores on toggle", async () => {
const user = userEvent.setup();
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Decode JWT for Access Token" }),
);
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Show token for Access Token" }),
);
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
});
it("omits decode toggle for opaque tokens", () => {
renderWithMantine(
<OAuthTokenField
label="Access Token"
token="opaque-access-token-value"
/>,
);
expect(
screen.queryByRole("button", { name: "Decode JWT for Access Token" }),
).not.toBeInTheDocument();
});
it("copies decoded JSON while decode view is shown", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
await user.click(
screen.getByRole("button", { name: "Decode JWT for Access Token" }),
);
await user.click(screen.getByRole("button", { name: "Copy Access Token" }));
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('"sub": "user"'),
);
expect(writeText).not.toHaveBeenCalledWith(jwt);
});
it("copies the raw token while token view is shown", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
await user.click(screen.getByRole("button", { name: "Copy Access Token" }));
expect(writeText).toHaveBeenCalledWith(jwt);
});
it("renders clear action on the access token header row", async () => {
const user = userEvent.setup();
const onClear = vi.fn();
renderWithMantine(
<OAuthTokenField
label="Access Token"
token="opaque-access-token-value"
onClear={onClear}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>,
);
await user.click(
screen.getByRole("button", {
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
}),
);
expect(onClear).toHaveBeenCalledTimes(1);
});
it("labels the row from the label prop and decodes an ID token", async () => {
const user = userEvent.setup();
// header {"alg":"none"} / payload {"sub":"user"} — same shape an AS returns
// for an `id_token`; the decode path is identical to the access token's.
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
expect(screen.getByText("ID Token")).toBeInTheDocument();
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Decode JWT for ID Token" }),
);
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
});
it("qualifies the decode and copy accessible names with the row label", () => {
// Two rows can render simultaneously (Access Token + ID Token), so the
// controls must not share accessible names — see #2019 review.
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
expect(
screen.getByRole("button", { name: "Decode JWT for ID Token" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Copy ID Token" }),
).toBeInTheDocument();
});
it("omits the clear action when no handler is given", () => {
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
expect(
screen.queryByRole("button", {
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
}),
).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,138 @@
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";
/**
* One labelled OAuth token row: the raw value, a copy control, and — when the
* value has the three-segment JWT shape — an in-place "Decode JWT" toggle.
*
* Shared by the Access Token and ID Token rows (#2019). The decode is
* display-only (`decodeJwtPayload` does no signature verification), so this is
* a viewer: neither token is treated as a credential here.
*/
export interface OAuthTokenFieldProps {
/** Row caption, e.g. "Access Token" / "ID Token". */
label: string;
token: string;
onClear?: () => void;
clearLabel?: string;
}
const CaptionRow = Flex.withProps({
justify: "space-between",
align: "center",
gap: "sm",
wrap: "nowrap",
});
const Caption = Text.withProps({ size: "sm" });
const Toolbar = Flex.withProps({
gap: 4,
align: "center",
wrap: "nowrap",
});
const ToolbarButton = Button.withProps({
variant: "subtle",
size: "compact-xs",
});
const TokenRow = Flex.withProps({
align: "flex-start",
gap: 4,
wrap: "nowrap",
});
const TokenColumn = Flex.withProps({
flex: 1,
miw: 0,
direction: "column",
});
const TokenCode = Code.withProps({
block: true,
py: "xs",
ps: "xs",
pe: 0,
variant: "wrapping",
});
/** 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 && "."}
{part}
</Fragment>
))}
</>
);
}
export function OAuthTokenField({
label,
token,
onClear,
clearLabel = "Clear",
}: OAuthTokenFieldProps) {
const [showDecoded, setShowDecoded] = useState(false);
const isJwt = isJwtFormat(token);
const jwtDecoded = useMemo(
() => (isJwt ? decodeJwtPayload(token) : undefined),
[token, isJwt],
);
const decodedText = useMemo(() => {
if (!jwtDecoded) return undefined;
return JSON.stringify(
{ header: jwtDecoded.header, payload: jwtDecoded.payload },
null,
2,
);
}, [jwtDecoded]);
const copyValue = showDecoded && decodedText ? decodedText : token;
return (
<Stack gap="xs">
<CaptionRow>
<Caption>{label}</Caption>
<Toolbar>
{jwtDecoded && (
<ToolbarButton
onClick={() => setShowDecoded((open) => !open)}
aria-pressed={showDecoded}
aria-label={`${showDecoded ? "Show token" : "Decode JWT"} for ${label}`}
>
{showDecoded ? "Show token" : "Decode JWT"}
</ToolbarButton>
)}
{onClear && (
<ToolbarButton color="red" onClick={onClear}>
{clearLabel}
</ToolbarButton>
)}
</Toolbar>
</CaptionRow>
<TokenRow>
<TokenColumn>
<TokenCode>
{showDecoded && decodedText ? (
decodedText
) : isJwt ? (
<JwtTokenText token={token} />
) : (
token
)}
</TokenCode>
</TokenColumn>
<CopyButton value={copyValue} flush label={label} />
</TokenRow>
</Stack>
);
}

View file

@ -34,6 +34,39 @@ describe("oauthDetailsFromConnectionState", () => {
});
});
it("surfaces an id_token when the token set carries one", () => {
const state: OAuthConnectionState = {
authorized: true,
protocol: "standard",
serverUrl: "https://mcp.example.com/mcp",
tokens: {
access_token: "tok",
token_type: "Bearer",
id_token: "header.payload.signature",
},
};
expect(oauthDetailsFromConnectionState(state)).toEqual({
protocol: "standard",
authorized: true,
accessToken: "tok",
idToken: "header.payload.signature",
});
});
it("omits idToken when the token set carries none", () => {
const state: OAuthConnectionState = {
authorized: true,
protocol: "standard",
serverUrl: "https://mcp.example.com/mcp",
tokens: { access_token: "tok", token_type: "Bearer" },
};
expect(oauthDetailsFromConnectionState(state)).not.toHaveProperty(
"idToken",
);
});
it("includes EMA idp session when present", () => {
const state: OAuthConnectionState = {
authorized: false,

View file

@ -22,6 +22,11 @@ export function oauthDetailsFromConnectionState(
...(state.tokens?.access_token && {
accessToken: state.tokens.access_token,
}),
// Surfaced for inspection when the AS returned one; omitted entirely
// otherwise so Connection Info renders no empty row (#2019).
...(state.tokens?.id_token && {
idToken: state.tokens.id_token,
}),
...(state.protocol === "ema" &&
state.ema?.idpSession && { idpSession: state.ema.idpSession }),
};

View file

@ -700,10 +700,272 @@ describe("ServerSettingsForm", () => {
clientId: "a",
clientSecret: "",
scopes: "",
authorizationParams: [],
enterpriseManaged: false,
});
});
// #2018 — custom authorization-request parameters.
it("shows the empty hint for authorization parameters when none are set", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getByText("Additional authorization parameters"),
).toBeInTheDocument();
expect(
screen.getByText("No additional parameters configured"),
).toBeInTheDocument();
});
it("adds an authorization-parameter row through onOAuthChange", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
await user.click(screen.getByRole("button", { name: "+ Add Parameter" }));
expect(onOAuthChange).toHaveBeenCalledWith(
expect.objectContaining({
authorizationParams: [{ key: "", value: "" }],
}),
);
});
it("edits and removes an authorization-parameter row", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={{
...emptySettings,
oauthAuthorizationParams: [{ key: "kc_idp_hint", value: "corp" }],
}}
expandedSections={["oauth"]}
/>,
);
const valueInput = screen.getByDisplayValue("corp");
await user.type(valueInput, "x");
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({
authorizationParams: [{ key: "kc_idp_hint", value: "corpx" }],
}),
);
onOAuthChange.mockClear();
const removeButton = screen
.getAllByRole("button")
.find((b) => b.textContent === "X");
await user.click(removeButton as HTMLElement);
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({ authorizationParams: [] }),
);
});
it("clears an authorization-parameter key via its Clear button", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={{
...emptySettings,
oauthAuthorizationParams: [{ key: "kc_idp_hint", value: "corp" }],
}}
expandedSections={["oauth"]}
/>,
);
await user.click(clearButtonFor(screen.getByDisplayValue("kc_idp_hint")));
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({
authorizationParams: [{ key: "", value: "corp" }],
}),
);
onOAuthChange.mockClear();
await user.click(clearButtonFor(screen.getByDisplayValue("corp")));
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({
authorizationParams: [{ key: "kc_idp_hint", value: "" }],
}),
);
});
it("rejects a reserved authorization-parameter key inline and with a warning", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthAuthorizationParams: [{ key: "state", value: "spoofed" }],
}}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getByText(
'"state" is set by the authorization flow and cannot be overridden.',
),
).toBeInTheDocument();
expect(screen.getByText("Reserved parameters ignored")).toBeInTheDocument();
expect(screen.getByDisplayValue("state")).toHaveAttribute(
"aria-invalid",
"true",
);
});
// #2018 — the section heading and the placeholders are not programmatically
// associated with these inputs, so each control carries its own aria-label and
// the reserved-key reason is the input's `error` string (which Mantine wires
// to the input via aria-describedby) rather than free-standing text.
it("gives each authorization-parameter control an accessible name", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthAuthorizationParams: [{ key: "kc_idp_hint", value: "corp" }],
}}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getByRole("textbox", {
name: "Authorization parameter name, kc_idp_hint",
}),
).toHaveValue("kc_idp_hint");
expect(
screen.getByRole("textbox", {
name: "Authorization parameter value, kc_idp_hint",
}),
).toHaveValue("corp");
expect(
screen.getByRole("button", {
name: "Remove authorization parameter kc_idp_hint",
}),
).toBeInTheDocument();
});
it("falls back to a row number in the accessible name when the key is blank", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthAuthorizationParams: [{ key: "", value: "" }],
}}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getByRole("textbox", {
name: "Authorization parameter name, row 1",
}),
).toBeInTheDocument();
});
// The reason must reach assistive tech through the input, not just as text on
// the page: Mantine emits the `error` string with an id the input references.
it("associates the reserved-key reason with the key input", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthAuthorizationParams: [{ key: "state", value: "spoofed" }],
}}
expandedSections={["oauth"]}
/>,
);
const keyInput = screen.getByRole("textbox", {
name: "Authorization parameter name, state",
});
const describedBy = keyInput.getAttribute("aria-describedby");
expect(describedBy).toBeTruthy();
const reason = describedBy
?.split(" ")
.map((id) => document.getElementById(id)?.textContent ?? "")
.join(" ");
expect(reason).toContain(
'"state" is set by the authorization flow and cannot be overridden.',
);
});
// #2018 — the EMA leg authorizes against the enterprise IdP, a different
// authorization server, and deliberately sends none of these parameters. The
// description has to say so, or the form shows a configured value as active
// when it will not be sent.
it("warns that authorization parameters are unused under enterprise-managed auth", () => {
const { rerender } = renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, enterpriseManaged: true }}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getByText(/Not sent while Enterprise-managed authorization is on/),
).toBeInTheDocument();
rerender(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, enterpriseManaged: false }}
expandedSections={["oauth"]}
/>,
);
expect(
screen.queryByText(
/Not sent while Enterprise-managed authorization is on/,
),
).not.toBeInTheDocument();
});
it("does not flag a blank authorization-parameter row as reserved", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthAuthorizationParams: [{ key: "", value: "" }],
}}
expandedSections={["oauth"]}
/>,
);
expect(
screen.queryByText("Reserved parameters ignored"),
).not.toBeInTheDocument();
});
it("pluralizes the reserved-parameter warning for multiple keys", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthAuthorizationParams: [
{ key: "state", value: "x" },
{ key: "scope", value: "y" },
],
}}
expandedSections={["oauth"]}
/>,
);
expect(screen.getByText(/state, scope are set/)).toBeInTheDocument();
});
it("invokes onOAuthChange with the chosen insufficient-scope policy (SEP-2350)", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
@ -744,6 +1006,7 @@ describe("ServerSettingsForm", () => {
clientId: "",
clientSecret: "",
scopes: "",
authorizationParams: [],
enterpriseManaged: true,
});
});
@ -848,6 +1111,7 @@ describe("ServerSettingsForm", () => {
clientId: "",
clientSecret: "z",
scopes: "",
authorizationParams: [],
enterpriseManaged: false,
});
});

View file

@ -1,6 +1,7 @@
import {
Accordion,
ActionIcon,
Alert,
Button,
Checkbox,
Flex,
@ -26,6 +27,11 @@ import {
MODERN_LOG_LEVELS,
} from "@inspector/core/mcp/types.js";
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
import {
RESERVED_AUTHORIZATION_PARAMS,
authorizationParamKeyError,
isReservedAuthorizationParam,
} from "@inspector/core/auth/authorizationParams.js";
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
import type { Root } from "@modelcontextprotocol/client";
@ -184,6 +190,25 @@ const EmptyHint = Text.withProps({
fs: "italic",
});
// Matches the label/description pair Mantine renders for a single input, so the
// authorization-parameter row list reads as one labelled field next to Scopes.
const FieldLabel = Text.withProps({
size: "sm",
fw: 500,
});
const FieldDescription = Text.withProps({
size: "xs",
c: "dimmed",
});
// `align="flex-start"` keeps the key/value/remove controls top-aligned when the
// key input grows an error message, so the row doesn't jump as it appears.
const AuthorizationParamRow = Group.withProps({
grow: true,
align: "flex-start",
});
const ClearStoredOAuthButton = Button.withProps({
variant: "light",
color: "red",
@ -245,6 +270,74 @@ function KeyValueRows({
);
}
// Reserved-key rejection is inline per row (#2018): the reason is passed as the
// key input's `error` **string**, so Mantine renders it in the input's own error
// slot and wires the `aria-describedby` association — a bare boolean would mark
// the field invalid without telling a screen reader why. The section-level Alert
// below repeats it so the rejection stays visible when the offending row is
// scrolled out of view.
//
// Each control carries a per-row `aria-label`: the section heading and the
// placeholders are not programmatically associated with the inputs, so without
// one an assistive technology cannot tell the key box from the value box, and
// the remove button announces only "X".
function AuthorizationParamRows({
params,
onChange,
onRemove,
}: {
params: { key: string; value: string }[];
onChange: (index: number, key: string, value: string) => void;
onRemove: (index: number) => void;
}) {
return (
<>
{params.map((param, index) => {
const rowLabel = param.key.trim() || `row ${index + 1}`;
return (
<AuthorizationParamRow key={index}>
<ClearableTextInput
aria-label={`Authorization parameter name, ${rowLabel}`}
placeholder="Parameter (e.g. kc_idp_hint)"
value={param.key}
error={authorizationParamKeyError(param.key)}
onChange={(e) =>
onChange(index, e.currentTarget.value, param.value)
}
rightSection={
param.key ? (
<ClearButton
onClick={() => onChange(index, "", param.value)}
/>
) : null
}
/>
<ClearableTextInput
aria-label={`Authorization parameter value, ${rowLabel}`}
placeholder="Value"
value={param.value}
onChange={(e) =>
onChange(index, param.key, e.currentTarget.value)
}
rightSection={
param.value ? (
<ClearButton onClick={() => onChange(index, param.key, "")} />
) : null
}
/>
<RemoveIcon
aria-label={`Remove authorization parameter ${rowLabel}`}
onClick={() => onRemove(index)}
>
X
</RemoveIcon>
</AuthorizationParamRow>
);
})}
</>
);
}
function RootRows({
roots,
onChange,
@ -374,16 +467,52 @@ export function ServerSettingsForm({
onTimeoutChange(field, numValue);
};
// #2018 — custom authorization-request parameters. The rows ride the single
// `onOAuthChange` callback with the rest of the Authorization section rather
// than growing three more props on this form.
const authorizationParams = settings.oauthAuthorizationParams ?? [];
function currentOAuth(): OAuthSettings {
return {
clientId: settings.oauthClientId ?? "",
clientSecret: settings.oauthClientSecret ?? "",
scopes: settings.oauthScopes ?? "",
authorizationParams,
enterpriseManaged: settings.enterpriseManaged ?? false,
onInsufficientScope: settings.oauthOnInsufficientScope,
};
}
const rejectedParamKeys = authorizationParams
.map((p) => p.key.trim())
.filter((key) => isReservedAuthorizationParam(key));
function changeAuthorizationParams(
params: { key: string; value: string }[],
): void {
onOAuthChange({ ...currentOAuth(), authorizationParams: params });
}
function handleAddAuthorizationParam(): void {
changeAuthorizationParams([...authorizationParams, { key: "", value: "" }]);
}
function handleRemoveAuthorizationParam(index: number): void {
changeAuthorizationParams(
authorizationParams.filter((_, i) => i !== index),
);
}
function handleAuthorizationParamChange(
index: number,
key: string,
value: string,
): void {
changeAuthorizationParams(
authorizationParams.map((p, i) => (i === index ? { key, value } : p)),
);
}
return (
// Stays inline: Accordion is a compound, `multiple`-discriminated generic,
// so `.withProps({ multiple: true, ... })` loses its JSX call signature
@ -699,6 +828,41 @@ export function ServerSettingsForm({
) : null
}
/>
<Stack gap="xs">
<FieldLabel>Additional authorization parameters</FieldLabel>
<FieldDescription>
Extra query parameters appended to the authorization request
only — never the token request. Use them for provider-specific
hints such as kc_idp_hint, login_hint, prompt, acr_values, or
audience.
{settings.enterpriseManaged
? " Not sent while Enterprise-managed authorization is on: that flow authorizes against the enterprise IdP, a different authorization server."
: ""}
</FieldDescription>
{authorizationParams.length === 0 ? (
<EmptyHint>No additional parameters configured</EmptyHint>
) : (
<AuthorizationParamRows
params={authorizationParams}
onChange={handleAuthorizationParamChange}
onRemove={handleRemoveAuthorizationParam}
/>
)}
{rejectedParamKeys.length > 0 ? (
<Alert color="red" title="Reserved parameters ignored">
{`${rejectedParamKeys.join(", ")} ${
rejectedParamKeys.length === 1 ? "is" : "are"
} set by the authorization flow and will not be sent. Overriding ${
rejectedParamKeys.length === 1 ? "it" : "them"
} would break PKCE, the CSRF state binding, or the resource indicator. Reserved: ${RESERVED_AUTHORIZATION_PARAMS.join(
", ",
)}.`}
</Alert>
) : null}
<AddButton onClick={handleAddAuthorizationParam}>
+ Add Parameter
</AddButton>
</Stack>
<Select
label="Insufficient-scope response"
description="On a 403 insufficient_scope challenge (SEP-2350): re-authorize with the accumulated scope union, or surface the error to you."

View file

@ -440,10 +440,35 @@ describe("ServerSettingsModal", () => {
oauthClientId: "",
oauthClientSecret: "",
oauthScopes: "",
oauthAuthorizationParams: [],
enterpriseManaged: true,
});
});
// #2018 — the authorization-parameter rows ride the same onOAuthChange
// callback, so the modal folds them onto the settings object.
it("persists an added authorization-parameter row onto the settings", 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("button", { name: "+ Add Parameter" }));
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({
oauthAuthorizationParams: [{ key: "", value: "" }],
}),
);
});
it("calls onSettingsChange with a blank row when adding a root", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();

View file

@ -179,6 +179,10 @@ export function ServerSettingsModal({
oauthClientId: oauth.clientId,
oauthClientSecret: oauth.clientSecret,
oauthScopes: oauth.scopes,
// #2018: kept as rows (blank ones included) so a half-typed parameter
// survives a re-render; the drop-blank/omit-empty filtering happens on the
// way to disk. An emptied list persists as `[]`, which writes nothing.
oauthAuthorizationParams: oauth.authorizationParams,
enterpriseManaged: oauth.enterpriseManaged ? true : undefined,
// SEP-2350: persist only the non-default ('throw') so unset servers keep
// the SDK's `reauthorize` behavior without writing a spurious field.