Merge branch 'v2/main' into v2/fix/1919-rfc6570-uri-template-expansion

This commit is contained in:
Cliff Hall 2026-08-16 23:16:27 -04:00 • committed by GitHub
commit 63250a6c52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2391 additions and 126 deletions

View file

@ -114,8 +114,10 @@ export const ServerInfoNotReported: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByText("my-catalog-name")).not.toBeInTheDocument();
expect(canvas.getAllByText(SERVER_INFO_NOT_REPORTED_LABEL)).toHaveLength(2);
await expect(canvas.queryByText("my-catalog-name")).not.toBeInTheDocument();
await expect(
canvas.getAllByText(SERVER_INFO_NOT_REPORTED_LABEL),
).toHaveLength(2);
},
};
@ -195,11 +197,11 @@ export const WithOAuthIdToken: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("ID Token")).toBeInTheDocument();
await 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();
await expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument();
},
};

View file

@ -77,6 +77,6 @@ export const WithEntries: Story = {
},
// List scrollbar reserves a gutter and stays hidden when idle (#1474).
play: async ({ canvasElement }) => {
expectScrollbarGutterIdleHidden(canvasElement);
await expectScrollbarGutterIdleHidden(canvasElement);
},
};

View file

@ -31,10 +31,10 @@ export const AllVisible: Story = {
args: { visibleDirections: { client: true, server: true } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByRole("button", { name: "client → server" }),
).toBeInTheDocument();
expect(
await expect(
canvas.getByRole("button", { name: "Deselect All" }),
).toBeInTheDocument();
},

View file

@ -20,7 +20,7 @@ type Story = StoryObj<typeof MonitoringControls>;
export const AllTabs: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("radio", { name: "Logs" })).toBeChecked();
await expect(canvas.getByRole("radio", { name: "Logs" })).toBeChecked();
},
};
@ -28,6 +28,6 @@ export const StdioServer: Story = {
args: { tabs: ["Logs", "Protocol"], value: "Protocol" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByRole("radio", { name: "Network" })).toBeNull();
await expect(canvas.queryByRole("radio", { name: "Network" })).toBeNull();
},
};

View file

@ -47,7 +47,7 @@ type Story = StoryObj<typeof MonitoringScreen>;
export const Logs: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Log stream")).toBeInTheDocument();
await expect(canvas.getByText("Log stream")).toBeInTheDocument();
},
};
@ -55,6 +55,6 @@ export const Protocol: Story = {
args: { value: "Protocol" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Request history")).toBeInTheDocument();
await expect(canvas.getByText("Request history")).toBeInTheDocument();
},
};

View file

@ -50,7 +50,7 @@ export const WithEntries: Story = {
args: { entries: sample },
// List scrollbar reserves a gutter and stays hidden when idle (#1474).
play: async ({ canvasElement }) => {
expectScrollbarGutterIdleHidden(canvasElement);
await expectScrollbarGutterIdleHidden(canvasElement);
},
};

View file

@ -45,14 +45,14 @@ export const Default: Story = {
// Mantine renders the modal in a portal at document.body.
const body = within(canvasElement.ownerDocument.body);
await body.findByText("Output schema validation");
expect(body.getByText(/open_pattern_editor/)).toBeInTheDocument();
await expect(body.getByText(/open_pattern_editor/)).toBeInTheDocument();
const details = body.getByLabelText(
"Validation details",
) as HTMLTextAreaElement;
expect(details.value).toContain(
await expect(details.value).toContain(
"data/samples/0 must NOT have additional properties",
);
expect(details.readOnly).toBe(true);
await expect(details.readOnly).toBe(true);
},
};

View file

@ -122,11 +122,11 @@ export const Sampling: Story = {
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await body.findByText("Sampling Request");
expect(
await expect(
body.getByText("The server is requesting an LLM completion."),
).toBeInTheDocument();
await userEvent.click(body.getByRole("button", { name: "Send Response" }));
expect(args.onSamplingRespond).toHaveBeenCalled();
await expect(args.onSamplingRespond).toHaveBeenCalled();
},
};
@ -144,8 +144,8 @@ export const SamplingInputRequired: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await body.findByText("Sampling Request");
expect(body.getByText("input_required")).toBeInTheDocument();
expect(body.getByText(/sent back as a retry/i)).toBeInTheDocument();
await expect(body.getByText("input_required")).toBeInTheDocument();
await expect(body.getByText(/sent back as a retry/i)).toBeInTheDocument();
},
};
@ -172,11 +172,11 @@ export const ElicitationForm: Story = {
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await body.findByText("Elicitation Request");
expect(
await expect(
body.getByText(/Please provide your database connection details/),
).toBeInTheDocument();
await userEvent.click(body.getByRole("button", { name: "Submit" }));
expect(args.onElicitationRespond).toHaveBeenCalledWith({
await expect(args.onElicitationRespond).toHaveBeenCalledWith({
action: "accept",
content: {},
});
@ -207,7 +207,7 @@ export const ElicitationUrl: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await body.findByText("Elicitation Request");
expect(
await expect(
body.getByText("https://example.com/authorize?token=abc123"),
).toBeInTheDocument();
},

View file

@ -107,7 +107,7 @@ export const WithEntries: Story = {
// bar never overlays the cards, and uses type="scroll" so it stays hidden
// when idle rather than appearing on hover (#1474).
play: async ({ canvasElement }) => {
expectScrollbarGutterIdleHidden(canvasElement);
await expectScrollbarGutterIdleHidden(canvasElement);
},
};

View file

@ -86,8 +86,8 @@ export const Default: Story = {
// chevron points down.
play: async ({ canvasElement }) => {
const transforms = chevronTransforms(canvasElement);
expect(transforms).toHaveLength(3);
for (const t of transforms) expect(t).toBe(ROTATED_DOWN);
await expect(transforms).toHaveLength(3);
for (const t of transforms) await expect(t).toBe(ROTATED_DOWN);
},
};
@ -122,9 +122,9 @@ export const Collapsed: Story = {
},
play: async ({ canvasElement }) => {
const transforms = chevronTransforms(canvasElement);
expect(transforms).toHaveLength(3);
await expect(transforms).toHaveLength(3);
// Identity matrix = the right-pointing arrow's natural 0° (not rotated).
for (const t of transforms) expect(t).toBe(NOT_ROTATED);
for (const t of transforms) await expect(t).toBe(NOT_ROTATED);
},
};
@ -140,10 +140,10 @@ export const EmptySectionCollapsed: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Subscriptions (0)")).toBeInTheDocument();
await expect(canvas.getByText("Subscriptions (0)")).toBeInTheDocument();
const transforms = chevronTransforms(canvasElement);
const downCount = transforms.filter((t) => t === ROTATED_DOWN).length;
expect(downCount).toBe(2); // URIs + Templates down, Subscriptions right
await expect(downCount).toBe(2); // URIs + Templates down, Subscriptions right
},
};
@ -158,11 +158,11 @@ export const SubscriptionsUnsupported: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByText(/Subscriptions/)).not.toBeInTheDocument();
await expect(canvas.queryByText(/Subscriptions/)).not.toBeInTheDocument();
// Only the two remaining sections render, both open → both chevrons down.
const transforms = chevronTransforms(canvasElement);
expect(transforms).toHaveLength(2);
for (const t of transforms) expect(t).toBe(ROTATED_DOWN);
await expect(transforms).toHaveLength(2);
for (const t of transforms) await expect(t).toBe(ROTATED_DOWN);
},
};

View file

@ -45,10 +45,12 @@ export const Static: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(URI)).toBeInTheDocument();
await expect(canvas.getByText(URI)).toBeInTheDocument();
// Copy button is present; there's no expand control in the static card.
expect(canvas.getByRole("button", { name: "Copy" })).toBeInTheDocument();
expect(
await expect(
canvas.getByRole("button", { name: "Copy" }),
).toBeInTheDocument();
await expect(
canvas.queryByRole("button", { name: /^Expand resource/ }),
).not.toBeInTheDocument();
},

View file

@ -701,6 +701,8 @@ describe("ServerSettingsForm", () => {
clientSecret: "",
scopes: "",
authorizationParams: [],
authorizationUrl: "",
tokenUrl: "",
enterpriseManaged: false,
});
});
@ -966,6 +968,104 @@ describe("ServerSettingsForm", () => {
expect(screen.getByText(/state, scope are set/)).toBeInTheDocument();
});
// #1906 — the endpoint overrides ride the same `onOAuthChange` callback as
// the rest of the Authorization section.
it("invokes onOAuthChange when an endpoint override is typed", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
await user.type(
screen.getByRole("textbox", { name: /Authorization URL override/i }),
"h",
);
expect(onOAuthChange).toHaveBeenCalledWith(
expect.objectContaining({ authorizationUrl: "h" }),
);
onOAuthChange.mockClear();
await user.type(
screen.getByRole("textbox", { name: /Token URL override/i }),
"h",
);
expect(onOAuthChange).toHaveBeenCalledWith(
expect.objectContaining({ tokenUrl: "h" }),
);
});
it("says the endpoint overrides are unused under enterprise-managed auth", () => {
const { rerender } = renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, enterpriseManaged: true }}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getAllByText(
/Not applied while Enterprise-managed authorization is on/,
),
).toHaveLength(2);
rerender(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, enterpriseManaged: false }}
expandedSections={["oauth"]}
/>,
);
expect(
screen.queryByText(
/Not applied while Enterprise-managed authorization is on/,
),
).not.toBeInTheDocument();
});
it("flags an endpoint override that is not an absolute http(s) URL", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthAuthorizationUrl: "/authorize",
oauthTokenUrl: "https://staging.test/token",
}}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getByText('"/authorize" is not an absolute URL.'),
).toBeInTheDocument();
expect(screen.queryByText(/is not an http\(s\) URL/)).toBeNull();
});
it("clears an endpoint override through its clear button", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={{
...emptySettings,
oauthTokenUrl: "https://staging.test/token",
}}
expandedSections={["oauth"]}
/>,
);
const clearButtons = screen.getAllByRole("button", { name: /clear/i });
await user.click(clearButtons[clearButtons.length - 1]);
expect(onOAuthChange).toHaveBeenCalledWith(
expect.objectContaining({ tokenUrl: "" }),
);
});
it("invokes onOAuthChange with the chosen insufficient-scope policy (SEP-2350)", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
@ -1007,6 +1107,8 @@ describe("ServerSettingsForm", () => {
clientSecret: "",
scopes: "",
authorizationParams: [],
authorizationUrl: "",
tokenUrl: "",
enterpriseManaged: true,
});
});
@ -1112,6 +1214,8 @@ describe("ServerSettingsForm", () => {
clientSecret: "z",
scopes: "",
authorizationParams: [],
authorizationUrl: "",
tokenUrl: "",
enterpriseManaged: false,
});
});

View file

@ -32,6 +32,7 @@ import {
authorizationParamKeyError,
isReservedAuthorizationParam,
} from "@inspector/core/auth/authorizationParams.js";
import { oauthEndpointUrlError } from "@inspector/core/auth/endpointOverrides.js";
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
import type { Root } from "@modelcontextprotocol/client";
@ -478,11 +479,20 @@ export function ServerSettingsForm({
clientSecret: settings.oauthClientSecret ?? "",
scopes: settings.oauthScopes ?? "",
authorizationParams,
authorizationUrl: settings.oauthAuthorizationUrl ?? "",
tokenUrl: settings.oauthTokenUrl ?? "",
enterpriseManaged: settings.enterpriseManaged ?? false,
onInsufficientScope: settings.oauthOnInsufficientScope,
};
}
// #1906 — the overrides are suppressed under EMA for the same reason the
// custom authorization parameters are: that leg authorizes against the
// enterprise IdP, a different authorization server.
const endpointOverrideEmaNote = settings.enterpriseManaged
? " Not applied while Enterprise-managed authorization is on: that flow authorizes against the enterprise IdP, a different authorization server."
: "";
const rejectedParamKeys = authorizationParams
.map((p) => p.key.trim())
.filter((key) => isReservedAuthorizationParam(key));
@ -863,6 +873,55 @@ export function ServerSettingsForm({
+ Add Parameter
</AddButton>
</Stack>
<ClearableTextInput
label="Authorization URL override"
description={`Leave blank to use the authorization_endpoint the authorization server's metadata advertises. Set it to point this server at a development or staging authorization server instead. Endpoints only — the discovered issuer is unchanged, so an authorization server advertising a different issuer is rejected on callback (RFC 9207).${endpointOverrideEmaNote}`}
placeholder="https://staging.auth.example.com/authorize"
value={settings.oauthAuthorizationUrl ?? ""}
error={oauthEndpointUrlError(
settings.oauthAuthorizationUrl ?? "",
)}
onChange={(e) =>
onOAuthChange({
...currentOAuth(),
authorizationUrl: e.currentTarget.value,
})
}
rightSection={
settings.oauthAuthorizationUrl ? (
<ClearButton
onClick={() =>
onOAuthChange({
...currentOAuth(),
authorizationUrl: "",
})
}
/>
) : null
}
/>
<ClearableTextInput
label="Token URL override"
description={`Leave blank to use the token_endpoint the authorization server's metadata advertises. Independent of the authorization URL — either can be overridden alone.${endpointOverrideEmaNote}`}
placeholder="https://staging.auth.example.com/token"
value={settings.oauthTokenUrl ?? ""}
error={oauthEndpointUrlError(settings.oauthTokenUrl ?? "")}
onChange={(e) =>
onOAuthChange({
...currentOAuth(),
tokenUrl: e.currentTarget.value,
})
}
rightSection={
settings.oauthTokenUrl ? (
<ClearButton
onClick={() =>
onOAuthChange({ ...currentOAuth(), tokenUrl: "" })
}
/>
) : null
}
/>
<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

@ -445,6 +445,52 @@ describe("ServerSettingsModal", () => {
});
});
// #1906 — a typed endpoint override lands on the settings; clearing it
// persists `undefined` rather than an empty string, so "cleared" and "never
// set" are the same state on disk.
it("persists a typed endpoint override, and clears it back to undefined", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const { rerender } = 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.type(
screen.getByRole("textbox", { name: /Token URL override/i }),
"h",
);
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ oauthTokenUrl: "h" }),
);
onSettingsChange.mockClear();
rerender(
<ServerSettingsModal
opened
settings={{
...emptySettings,
oauthTokenUrl: "https://staging.test/token",
}}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
const clearButtons = screen.getAllByRole("button", { name: /clear/i });
await user.click(clearButtons[clearButtons.length - 1]);
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ oauthTokenUrl: undefined }),
);
});
// #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 () => {

View file

@ -183,6 +183,11 @@ export function ServerSettingsModal({
// 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,
// #1906: an emptied field persists as undefined rather than `""` — the
// read side treats a blank override as "not configured", and keeping the
// two in step means clearing the input really clears the setting.
oauthAuthorizationUrl: oauth.authorizationUrl || undefined,
oauthTokenUrl: oauth.tokenUrl || undefined,
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.

View file

@ -41,7 +41,7 @@ export const Nested: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByRole("heading", { name: "Structured Output" }),
).toBeInTheDocument();
// The section starts expanded, so the payload is visible without a click.
@ -78,9 +78,9 @@ export const Large: Story = {
}
// Capped: the visible height stays at the section's `mah`, well under the
// payload's natural height…
expect(viewport.clientHeight).toBeLessThanOrEqual(400);
await expect(viewport.clientHeight).toBeLessThanOrEqual(400);
// …and the overflow is reachable by scrolling rather than clipped away.
expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight);
await expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight);
},
};
@ -95,7 +95,7 @@ export const Collapsed: Story = {
const toggle = canvas.getByRole("button", {
name: "Expand structured output",
});
expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await userEvent.click(toggle);
await waitFor(() =>
expect(

View file

@ -47,10 +47,10 @@ export const Default: Story = {
// Mantine renders the modal in a portal at document.body.
const body = within(canvasElement.ownerDocument.body);
await body.findByText("URL elicitation required");
expect(body.getByText(/trigger-url-elicitation/)).toBeInTheDocument();
await expect(body.getByText(/trigger-url-elicitation/)).toBeInTheDocument();
const details = body.getByLabelText("Error details") as HTMLTextAreaElement;
expect(details.value).toContain("-32042");
expect(details.readOnly).toBe(true);
await expect(details.value).toContain("-32042");
await expect(details.readOnly).toBe(true);
},
};