Merge branch 'v2/main' into v2/feat/1915-manual-server-headers

This commit is contained in:
Cliff Hall 2026-08-16 23:21:17 -04:00 • committed by GitHub
commit 4d7405bea0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2747 additions and 136 deletions

View file

@ -14,7 +14,7 @@ export const Transport: Story = {
args: { category: "transport" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("transport")).toBeInTheDocument();
await expect(canvas.getByText("transport")).toBeInTheDocument();
},
};
@ -22,6 +22,6 @@ export const Auth: Story = {
args: { category: "auth" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("auth")).toBeInTheDocument();
await expect(canvas.getByText("auth")).toBeInTheDocument();
},
};

View file

@ -24,7 +24,7 @@ export const FullSize: Story = {
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Row 1")).toBeInTheDocument();
await expect(canvas.getByText("Row 1")).toBeInTheDocument();
},
};
@ -38,6 +38,6 @@ export const Embedded: Story = {
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Row 1")).toBeInTheDocument();
await expect(canvas.getByText("Row 1")).toBeInTheDocument();
},
};

View file

@ -15,7 +15,9 @@ export const Collapsed: Story = {
args: { expanded: false },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("button", { name: "Expand" })).toBeInTheDocument();
await expect(
canvas.getByRole("button", { name: "Expand" }),
).toBeInTheDocument();
},
};
@ -23,7 +25,7 @@ export const Expanded: Story = {
args: { expanded: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByRole("button", { name: "Collapse" }),
).toBeInTheDocument();
},

View file

@ -14,7 +14,7 @@ export const HeaderMismatch: Story = {
args: { code: -32020, name: "HeaderMismatch" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("-32020 HeaderMismatch")).toBeInTheDocument();
await expect(canvas.getByText("-32020 HeaderMismatch")).toBeInTheDocument();
},
};
@ -22,7 +22,7 @@ export const MissingCapability: Story = {
args: { code: -32021, name: "MissingRequiredClientCapability" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByText("-32021 MissingRequiredClientCapability"),
).toBeInTheDocument();
},
@ -32,7 +32,7 @@ export const UnsupportedVersion: Story = {
args: { code: -32022, name: "UnsupportedProtocolVersion" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByText("-32022 UnsupportedProtocolVersion"),
).toBeInTheDocument();
},
@ -47,6 +47,6 @@ export const MethodNotFound: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("-32601 MethodNotFound")).toBeInTheDocument();
await expect(canvas.getByText("-32601 MethodNotFound")).toBeInTheDocument();
},
};

View file

@ -14,7 +14,7 @@ export const Outgoing: Story = {
args: { direction: "outgoing" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("client → server")).toBeInTheDocument();
await expect(canvas.getByText("client → server")).toBeInTheDocument();
},
};
@ -22,6 +22,6 @@ export const Incoming: Story = {
args: { direction: "incoming" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("server → client")).toBeInTheDocument();
await expect(canvas.getByText("server → client")).toBeInTheDocument();
},
};

View file

@ -14,7 +14,7 @@ export const ToolsList: Story = {
args: { method: "tools/list" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("tools/list")).toBeInTheDocument();
await expect(canvas.getByText("tools/list")).toBeInTheDocument();
},
};
@ -22,6 +22,6 @@ export const TasksList: Story = {
args: { method: "tasks/list" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("tasks/list")).toBeInTheDocument();
await expect(canvas.getByText("tasks/list")).toBeInTheDocument();
},
};

View file

@ -15,7 +15,7 @@ export const Closed: Story = {
args: { open: false },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByRole("button", { name: "Open monitoring sidebar" }),
).toBeInTheDocument();
},
@ -25,7 +25,7 @@ export const Open: Story = {
args: { open: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByRole("button", { name: "Close monitoring sidebar" }),
).toBeInTheDocument();
},

View file

@ -15,7 +15,9 @@ export const Unpinned: Story = {
args: { pinned: false },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("button", { name: "Pin" })).toBeInTheDocument();
await expect(
canvas.getByRole("button", { name: "Pin" }),
).toBeInTheDocument();
},
};
@ -23,6 +25,8 @@ export const Pinned: Story = {
args: { pinned: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("button", { name: "Unpin" })).toBeInTheDocument();
await expect(
canvas.getByRole("button", { name: "Unpin" }),
).toBeInTheDocument();
},
};

View file

@ -14,6 +14,8 @@ type Story = StoryObj<typeof ReplayButton>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("button", { name: "Replay" })).toBeInTheDocument();
await expect(
canvas.getByRole("button", { name: "Replay" }),
).toBeInTheDocument();
},
};

View file

@ -44,7 +44,7 @@ export const Default: Story = {
render: () => <ResizeDemo />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await expect(
canvas.getByRole("separator", { name: "Resize demo panel" }),
).toBeInTheDocument();
},

View file

@ -48,7 +48,7 @@ export const FillsHostWidth: Story = {
// sharper discriminator: a row flex leaves the layer full-width while
// collapsing the child to the single "x").
const layer = child.parentElement as HTMLElement;
expect(child.getBoundingClientRect().width).toBeCloseTo(
await expect(child.getBoundingClientRect().width).toBeCloseTo(
layer.getBoundingClientRect().width,
0,
);
@ -77,11 +77,11 @@ export const FillVariant: Story = {
// Width still stretches onto the child (relative to the layer, as above);
// the `fill` height comes from `bottom: 0` making the layer span the host.
const layer = child.parentElement as HTMLElement;
expect(child.getBoundingClientRect().width).toBeCloseTo(
await expect(child.getBoundingClientRect().width).toBeCloseTo(
layer.getBoundingClientRect().width,
0,
);
expect(layer.getBoundingClientRect().height).toBeGreaterThan(160 - 4);
await expect(layer.getBoundingClientRect().height).toBeGreaterThan(160 - 4);
},
};
@ -97,6 +97,6 @@ export const Inactive: Story = {
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByTestId("stage-child")).not.toBeInTheDocument();
await expect(canvas.queryByTestId("stage-child")).not.toBeInTheDocument();
},
};

View file

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

View file

@ -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

View file

@ -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,

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

Some files were not shown because too many files have changed in this diff Show more