Merge branch 'v2/main' into v2/feat/1915-manual-server-headers
This commit is contained in:
commit
4d7405bea0
21 changed files with 2747 additions and 136 deletions
|
|
@ -77,6 +77,14 @@ export const ModernEra: Story = {
|
|||
},
|
||||
},
|
||||
},
|
||||
// SEP-2663 moved task support to the extension map, so the Tasks capability
|
||||
// row must read it there on a modern connection — otherwise a tasks-capable
|
||||
// server shows ✗ while listing the extension below (#1887).
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const tasksRow = canvas.getByText("Tasks");
|
||||
await expect(tasksRow.previousElementSibling).toHaveTextContent("✓");
|
||||
},
|
||||
};
|
||||
|
||||
// A modern server that omitted the optional `_meta` serverInfo stamp (#1772).
|
||||
|
|
@ -106,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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -187,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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,6 +31,21 @@ const fullClientCaps: ClientCapabilities = {
|
|||
experimental: {},
|
||||
};
|
||||
|
||||
// The glyphs `CapabilityItem` renders beside each capability label.
|
||||
const SUPPORTED_MARK = "✓";
|
||||
const UNSUPPORTED_MARK = "✗";
|
||||
|
||||
/**
|
||||
* Read the ✓/✗ a capability row is showing. The mark is the label's preceding
|
||||
* sibling inside the row `Group`, so asserting on it — rather than on the
|
||||
* label's mere presence — is what distinguishes "supported" from "listed".
|
||||
*/
|
||||
function capabilityMark(label: string): string | undefined {
|
||||
return (
|
||||
screen.getByText(label).previousElementSibling?.textContent ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
describe("ConnectionInfoContent", () => {
|
||||
it("renders server implementation fields under the heading", () => {
|
||||
renderWithMantine(
|
||||
|
|
@ -181,6 +196,96 @@ describe("ConnectionInfoContent", () => {
|
|||
expect(screen.getByText("Sampling")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks Tasks supported on a modern connection that advertises the tasks extension (#1887)", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
capabilities: {
|
||||
...fullResult.capabilities,
|
||||
// SEP-2663: no top-level `tasks` key — support is the extension.
|
||||
extensions: { "io.modelcontextprotocol/tasks": {} },
|
||||
},
|
||||
}}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="modern"
|
||||
/>,
|
||||
);
|
||||
expect(capabilityMark("Tasks")).toBe(SUPPORTED_MARK);
|
||||
});
|
||||
|
||||
it("still marks Tasks unsupported on a modern connection without the extension (#1887)", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="modern"
|
||||
/>,
|
||||
);
|
||||
expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK);
|
||||
});
|
||||
|
||||
it("does not read the tasks extension on a legacy connection (#1887)", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
capabilities: {
|
||||
...fullResult.capabilities,
|
||||
extensions: { "io.modelcontextprotocol/tasks": {} },
|
||||
},
|
||||
}}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="legacy"
|
||||
/>,
|
||||
);
|
||||
// Legacy tasks support is `capabilities.tasks`, which this server omits —
|
||||
// an extension key alone must not turn the row green.
|
||||
expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK);
|
||||
});
|
||||
|
||||
it("marks Tasks supported on a legacy connection from capabilities.tasks", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
capabilities: {
|
||||
...fullResult.capabilities,
|
||||
// `ServerTasksCapability`'s sub-capabilities are objects, not
|
||||
// booleans — `{}` is the "supported, no sub-options" shape.
|
||||
tasks: { list: {}, cancel: {} },
|
||||
},
|
||||
}}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="legacy"
|
||||
/>,
|
||||
);
|
||||
expect(capabilityMark("Tasks")).toBe(SUPPORTED_MARK);
|
||||
});
|
||||
|
||||
it("does not extension-promote a capability other than tasks (#1887)", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
capabilities: {
|
||||
tools: { listChanged: true },
|
||||
extensions: { "io.modelcontextprotocol/ui": {} },
|
||||
},
|
||||
}}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="modern"
|
||||
/>,
|
||||
);
|
||||
expect(capabilityMark("Prompts")).toBe(UNSUPPORTED_MARK);
|
||||
expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK);
|
||||
});
|
||||
|
||||
it("renders instructions when present", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ import type {
|
|||
DiscoverResult,
|
||||
InitializeResult,
|
||||
ProtocolEra,
|
||||
ServerCapabilities,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { ServerType } from "@inspector/core/mcp/types.js";
|
||||
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
|
||||
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
|
||||
import {
|
||||
CapabilityItem,
|
||||
|
|
@ -189,16 +191,62 @@ const CLIENT_CAPABILITY_KEYS: CapabilityKey[] = [
|
|||
export const CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL =
|
||||
"Clear OAuth state and disconnect";
|
||||
|
||||
/**
|
||||
* Server capabilities the modern (2026-07-28) era expresses as a negotiated
|
||||
* *extension* rather than a top-level `capabilities` key. `tasks` is the only
|
||||
* one today: SEP-2663 moved task support to
|
||||
* `capabilities.extensions["io.modelcontextprotocol/tasks"]`, so a modern
|
||||
* tasks-capable server left the Tasks row showing a red ✗ while the very same
|
||||
* extension id was listed under "Server Extensions" two sections below
|
||||
* (#1887). Keyed the same way `InspectorClient.isTasksExtensionNegotiated()`
|
||||
* gates the Tasks tab, so the checkmark and the tab agree.
|
||||
*/
|
||||
const MODERN_EXTENSION_BACKED_CAPABILITIES: Partial<
|
||||
Record<CapabilityKey, string>
|
||||
> = {
|
||||
tasks: TASKS_EXTENSION_KEY,
|
||||
};
|
||||
|
||||
function isCapabilityPresent(
|
||||
capabilities: Record<string, unknown>,
|
||||
key: CapabilityKey,
|
||||
): boolean {
|
||||
return key in capabilities && capabilities[key] != null;
|
||||
}
|
||||
|
||||
function getCapabilityEntries(
|
||||
capabilities: Record<string, unknown>,
|
||||
knownKeys: CapabilityKey[],
|
||||
): { capability: CapabilityKey; supported: boolean }[] {
|
||||
return knownKeys.map((key) => ({
|
||||
capability: key,
|
||||
supported: key in capabilities && capabilities[key] != null,
|
||||
supported: isCapabilityPresent(capabilities, key),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side capability entries. Same presence rule as the client column,
|
||||
* plus the modern extension fallback above — gated on the negotiated era so a
|
||||
* legacy connection is still judged purely on its `initialize` capabilities.
|
||||
*/
|
||||
function getServerCapabilityEntries(
|
||||
capabilities: ServerCapabilities,
|
||||
era: ProtocolEra | undefined,
|
||||
): { capability: CapabilityKey; supported: boolean }[] {
|
||||
const modern = isModernEra(era);
|
||||
return SERVER_CAPABILITY_KEYS.map((key) => {
|
||||
const extensionKey = MODERN_EXTENSION_BACKED_CAPABILITIES[key];
|
||||
const viaExtension =
|
||||
modern &&
|
||||
extensionKey !== undefined &&
|
||||
capabilities.extensions?.[extensionKey] != null;
|
||||
return {
|
||||
capability: key,
|
||||
supported: isCapabilityPresent(capabilities, key) || viaExtension,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function ConnectionInfoContent({
|
||||
initializeResult,
|
||||
serverInfoReported = true,
|
||||
|
|
@ -229,7 +277,7 @@ export function ConnectionInfoContent({
|
|||
? serverInfo.version?.trim() || "—"
|
||||
: SERVER_INFO_NOT_REPORTED_LABEL;
|
||||
|
||||
const serverCaps = getCapabilityEntries(capabilities, SERVER_CAPABILITY_KEYS);
|
||||
const serverCaps = getServerCapabilityEntries(capabilities, protocolEra);
|
||||
const clientCaps = getCapabilityEntries(
|
||||
clientCapabilities,
|
||||
CLIENT_CAPABILITY_KEYS,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -714,6 +714,8 @@ describe("ServerSettingsForm", () => {
|
|||
clientSecret: "",
|
||||
scopes: "",
|
||||
authorizationParams: [],
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
enterpriseManaged: false,
|
||||
});
|
||||
});
|
||||
|
|
@ -979,6 +981,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();
|
||||
|
|
@ -1020,6 +1120,8 @@ describe("ServerSettingsForm", () => {
|
|||
clientSecret: "",
|
||||
scopes: "",
|
||||
authorizationParams: [],
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
enterpriseManaged: true,
|
||||
});
|
||||
});
|
||||
|
|
@ -1125,6 +1227,8 @@ describe("ServerSettingsForm", () => {
|
|||
clientSecret: "z",
|
||||
scopes: "",
|
||||
authorizationParams: [],
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
enterpriseManaged: false,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -509,11 +510,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));
|
||||
|
|
@ -899,6 +909,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."
|
||||
|
|
|
|||
|
|
@ -451,6 +451,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 () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -35,11 +35,20 @@ describe("ToolResultPanel", () => {
|
|||
expect(screen.getByText("boom")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the empty state when content is empty", () => {
|
||||
// #1860: this panel only mounts once a result exists, so an empty result must
|
||||
// read as "the call completed and returned nothing" — never as the pre-run
|
||||
// "No results yet" placeholder, which denies a call the user watched succeed.
|
||||
it("names the outcome when a completed result has no content", () => {
|
||||
renderWithMantine(
|
||||
<ToolResultPanel result={emptyResult} onClear={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText("No results yet")).toBeInTheDocument();
|
||||
expect(screen.getByText("Empty result")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"The tool call completed successfully and returned no content.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("No results yet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups resource_link blocks in a scrollable Resource Links box", async () => {
|
||||
|
|
@ -141,7 +150,7 @@ describe("ToolResultPanel", () => {
|
|||
onClear={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("No results yet")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Empty result")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Structured Output" }),
|
||||
).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type {
|
||||
|
|
@ -176,6 +175,16 @@ const ErrorAlert = Alert.withProps({
|
|||
title: "Tool Error",
|
||||
});
|
||||
|
||||
// A completed call whose result renders nothing. `content: []` with no
|
||||
// `structuredContent` is a legal CallToolResult, and this panel is only mounted
|
||||
// once a result exists — so saying "no results yet" here would deny a call the
|
||||
// user just watched succeed (#1860). Name the outcome instead.
|
||||
const EmptyResultAlert = Alert.withProps({
|
||||
color: "gray",
|
||||
variant: "light",
|
||||
title: "Empty result",
|
||||
});
|
||||
|
||||
function ResourceLinksGroup({
|
||||
links,
|
||||
onReadResource,
|
||||
|
|
@ -277,7 +286,9 @@ export function ToolResultPanel({
|
|||
</ResultScroll>
|
||||
) : result.content.length === 0 && !structuredNode ? (
|
||||
<ResultScroll>
|
||||
<Text c="dimmed">No results yet</Text>
|
||||
<EmptyResultAlert>
|
||||
The tool call completed successfully and returned no content.
|
||||
</EmptyResultAlert>
|
||||
</ResultScroll>
|
||||
) : hasLinks ? (
|
||||
<FillStack>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue