fix(web): make the Connection Info extension sections say something

Three things the Skills work got wrong in that modal, all found by review
of the PR screenshots.

- The "Skills Extension" section repeated `io.modelcontextprotocol/skills`
  as its value — the identifier the "Server Extensions" section two rows
  above already lists, so the section added nothing. What a flat key list
  *cannot* show is the extension's sub-options, which is the fact a server
  author opens this modal to check. Renamed "Skills Extension Options" and
  rendered as a ✓/✗ row for `directoryRead`, in the same vocabulary as the
  capability columns, so it reads as the same kind of claim.
- The extension sections' contents were bold (`ValueText`, the value half
  of a label/value pair) while sitting directly beneath the capability
  checklists, which are plain. They are lists of items, not values, so
  they now use the same `Text` the checklist rows do.
- Those lists were comma-joined into one line, which wraps mid-identifier
  in a half-width column. One row per identifier.

The `skills-directory-read` tests now assert `data-supported` rather than
the copy: "Not supported" contains "Supported", so a text assertion passed
for either answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-04 22:57:51 -04:00
commit 27fafe6acb
2 changed files with 73 additions and 37 deletions

View file

@ -427,11 +427,12 @@ describe("ConnectionInfoContent", () => {
expect( expect(
screen.getByText("Client Advertised Extensions"), screen.getByText("Client Advertised Extensions"),
).toBeInTheDocument(); ).toBeInTheDocument();
// One row per identifier, not a comma-joined string: two ~30-character
// ids wrap mid-name in a half-width column.
expect( expect(
screen.getByText( screen.getByText("io.modelcontextprotocol/tasks"),
"io.modelcontextprotocol/tasks, io.modelcontextprotocol/ui",
),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("io.modelcontextprotocol/ui")).toBeInTheDocument();
}); });
it("renders em-dashes for the extensions sections when neither side advertises any (#1740)", () => { it("renders em-dashes for the extensions sections when neither side advertises any (#1740)", () => {
@ -461,7 +462,9 @@ describe("ConnectionInfoContent", () => {
protocolEra="legacy" protocolEra="legacy"
/>, />,
); );
expect(screen.queryByText("Skills Extension")).not.toBeInTheDocument(); expect(
screen.queryByText("Skills Extension Options"),
).not.toBeInTheDocument();
}); });
it("shows the Skills extension and its directoryRead sub-flag (#2234)", () => { it("shows the Skills extension and its directoryRead sub-flag (#2234)", () => {
@ -484,10 +487,16 @@ describe("ConnectionInfoContent", () => {
protocolEra="legacy" protocolEra="legacy"
/>, />,
); );
expect(screen.getByText("Skills Extension")).toBeInTheDocument(); expect(screen.getByText("Skills Extension Options")).toBeInTheDocument();
expect(screen.getByTestId("skills-directory-read")).toHaveTextContent( // Asserted on the attribute, not the copy: "Not supported" contains
"Supported", // "Supported", so a text check would pass for either answer.
expect(screen.getByTestId("skills-directory-read")).toHaveAttribute(
"data-supported",
"true",
); );
// The section states the sub-option, not the identifier — that is already
// in "Server Extensions" and repeating it would add nothing.
expect(screen.getByText("resources/directory/read")).toBeInTheDocument();
}); });
it("reports directory read as unsupported for a bare skills declaration (#2234)", () => { it("reports directory read as unsupported for a bare skills declaration (#2234)", () => {
@ -505,8 +514,9 @@ describe("ConnectionInfoContent", () => {
protocolEra="legacy" protocolEra="legacy"
/>, />,
); );
expect(screen.getByTestId("skills-directory-read")).toHaveTextContent( expect(screen.getByTestId("skills-directory-read")).toHaveAttribute(
"Not supported", "data-supported",
"false",
); );
}); });

View file

@ -3,6 +3,7 @@ import {
Button, Button,
Code, Code,
Flex, Flex,
Group,
ScrollArea, ScrollArea,
SimpleGrid, SimpleGrid,
Stack, Stack,
@ -18,7 +19,6 @@ import type {
} from "@modelcontextprotocol/client"; } from "@modelcontextprotocol/client";
import type { ServerType } from "@inspector/core/mcp/types.js"; import type { ServerType } from "@inspector/core/mcp/types.js";
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js"; import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
import { SKILLS_EXTENSION_KEY } from "@inspector/core/mcp/skillsSchemas.js";
import { getSkillsExtension } from "@inspector/core/mcp/skills.js"; import { getSkillsExtension } from "@inspector/core/mcp/skills.js";
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js"; import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
import { import {
@ -106,6 +106,15 @@ const SectionHeading = Title.withProps({
// `scrollable-region-focusable`). // `scrollable-region-focusable`).
const ValueCode = Code.withProps({ variant: "wrapping" }); const ValueCode = Code.withProps({ variant: "wrapping" });
// One declared sub-option of an extension. Mirrors `CapabilityItem`'s ✓/✗ row
// rather than reusing it: that element's `capability` prop is the closed union
// of spec capability keys, and widening it to accept an arbitrary extension
// sub-option name would collapse it to `string` and lose the typo protection
// the union buys every other caller.
const SubOptionRow = Group.withProps({ gap: "xs", wrap: "nowrap" });
const SubOptionMark = Text.withProps({ fw: 600 });
const ClearOAuthButton = Button.withProps({ const ClearOAuthButton = Button.withProps({
variant: "subtle", variant: "subtle",
color: "red", color: "red",
@ -161,16 +170,20 @@ function formatSession(
return isModernEra(era) ? "Sessionless" : "Session-based"; return isModernEra(era) ? "Sessionless" : "Session-based";
} }
// Render an `extensions` capability map (SEP-2133) as a comma-separated list of // The extension identifiers in an `extensions` capability map (SEP-2133), one
// its extension identifiers, or an em dash when none are present. Works for // per rendered row, or a single em dash when none are present. Works for either
// either side's map: the server's negotiated `capabilities.extensions` (present // side's map: the server's negotiated `capabilities.extensions` (present on both
// on both eras via `getServerCapabilities()`) or the Inspector's own advertised // eras via `getServerCapabilities()`) or the Inspector's own advertised
// `clientCapabilities.extensions`. (#1740) // `clientCapabilities.extensions`. (#1740)
//
// A list rather than a comma-joined string (#2234): an identifier is ~30
// characters and two of them wrap mid-name in a half-width column, which is
// what made the joined form hard to read at a glance.
function formatExtensions( function formatExtensions(
extensions: Record<string, unknown> | undefined, extensions: Record<string, unknown> | undefined,
): string { ): string[] {
const keys = extensions ? Object.keys(extensions) : []; const keys = extensions ? Object.keys(extensions) : [];
return keys.length > 0 ? keys.join(", ") : "—"; return keys.length > 0 ? keys : ["\u2014"];
} }
const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [ const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [
@ -357,34 +370,47 @@ export function ConnectionInfoContent({
<SimpleGrid cols={2}> <SimpleGrid cols={2}>
<Stack gap="xs"> <Stack gap="xs">
<SectionHeading>Server Extensions</SectionHeading> <SectionHeading>Server Extensions</SectionHeading>
<ValueText>{formatExtensions(capabilities.extensions)}</ValueText> {/* A plain `Text`, not the bold `ValueText`: these sections list
*items*, the way the capability columns above do, rather than
giving the value half of a label/value pair. Bolding them made
them read as emphasized answers to a question the section never
asks, and set them in a different font from the checklist rows
they sit directly beneath. */}
{formatExtensions(capabilities.extensions).map((extension) => (
<Text key={extension}>{extension}</Text>
))}
</Stack> </Stack>
<Stack gap="xs"> <Stack gap="xs">
<SectionHeading>Client Advertised Extensions</SectionHeading> <SectionHeading>Client Advertised Extensions</SectionHeading>
<ValueText> {formatExtensions(clientCapabilities.extensions).map((extension) => (
{formatExtensions(clientCapabilities.extensions)} <Text key={extension}>{extension}</Text>
</ValueText> ))}
</Stack> </Stack>
</SimpleGrid> </SimpleGrid>
{/* Skills (SEP-2640). The generic "Server Extensions" row above lists the {/* Skills (SEP-2640). The "Server Extensions" row above already names the
identifier, but not the one sub-option the extension defines — identifier, so repeating it here would say nothing: what this section
`directoryRead`, which gates `resources/directory/read`. That flag is adds is the extension's SUB-OPTIONS, which a flat list of keys cannot
exactly what a server author opens this modal to confirm, so it gets a show. `directoryRead` is the only one SEP-2640 defines, and whether a
row of its own rather than being flattened into a key list (#2234). */} server declared it is the fact a server author opens this modal to
check — it gates `resources/directory/read` (#2234). Rendered with the
same ✓/✗ vocabulary as the capability columns above so it reads as the
same kind of claim. */}
{skillsExtension && ( {skillsExtension && (
<SimpleGrid cols={2}> <Stack gap="xs">
<Stack gap="xs"> <SectionHeading>Skills Extension Options</SectionHeading>
<SectionHeading>Skills Extension</SectionHeading> <SubOptionRow
<ValueText>{SKILLS_EXTENSION_KEY}</ValueText> data-testid="skills-directory-read"
</Stack> data-supported={skillsExtension.directoryRead}
<Stack gap="xs"> >
<SectionHeading>Directory Read</SectionHeading> <SubOptionMark c={skillsExtension.directoryRead ? "green" : "red"}>
<ValueText data-testid="skills-directory-read"> {skillsExtension.directoryRead ? "\u2713" : "\u2717"}
{skillsExtension.directoryRead ? "Supported" : "Not supported"} </SubOptionMark>
</ValueText> <Text>
</Stack> Directory read — <Code>resources/directory/read</Code>
</SimpleGrid> </Text>
</SubOptionRow>
</Stack>
)} )}
{instructions && ( {instructions && (