Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
63623b7
feat: support the Skills extension (SEP-2640) with digest verification
cliffhall Sep 5, 2026
da8cc16
fix: address Copilot review round 1 on #2251
cliffhall Sep 5, 2026
bfeebd4
fix: address Copilot review round 2 on #2251
cliffhall Sep 5, 2026
6a08c77
fix: address Copilot review round 3 on #2251
cliffhall Sep 5, 2026
1cb05ca
fix: address Copilot review round 4 on #2251
cliffhall Sep 5, 2026
27fafe6
fix(web): make the Connection Info extension sections say something
cliffhall Sep 5, 2026
754ce5b
fix: address Copilot review round 5 on #2251
cliffhall Sep 5, 2026
4e676a0
fix: address Copilot review round 6 on #2251
cliffhall Sep 5, 2026
1f33ba9
fix: address Copilot review round 7 on #2251
cliffhall Sep 5, 2026
7e291f9
fix: address Copilot review round 8 on #2251
cliffhall Sep 5, 2026
c1a7cfb
fix: address Copilot review round 9 on #2251
cliffhall Sep 5, 2026
9d162f3
fix: address Copilot review round 10 on #2251
cliffhall Sep 5, 2026
d030207
fix: address Copilot review round 11 on #2251
cliffhall Sep 5, 2026
63f89fa
Merge branch 'v2/main' into v2/feat/2234-skills-extension
cliffhall Sep 5, 2026
18724b2
Merge branch 'v2/main' into v2/feat/2234-skills-extension
cliffhall Sep 5, 2026
bd28a36
fix: address Copilot review round 12 on #2251
cliffhall Sep 5, 2026
4d7dd18
fix: address Copilot review round 13 on #2251
cliffhall Sep 5, 2026
11ac134
feat: require the modern list envelope on skills/list (review round 14)
cliffhall Sep 5, 2026
a561cd1
fix: address Copilot review round 15 on #2251
cliffhall Sep 5, 2026
1ee9580
feat(skills): address Copilot review round 16 on #2234
cliffhall Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions clients/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,12 @@ Nothing _enforces_ the boundary — no path alias keys off it, and the coverage

## Core tab automation contract

The Tools, Resources, and Prompts screens each expose a `data-testid` plus a
The Tools, Resources, Prompts and Skills screens each expose a `data-testid` plus a
small set of `data-*` attributes, so a headless driver can `waitForSelector` on
a deterministic signal rather than on visible copy. `scripts/smoke-web-tabs.mjs`
drives all three against `test-servers/configs/web-tabs-http.json` ([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)).
drives the first three of those four against `test-servers/configs/web-tabs-http.json`
([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)); Skills
publishes the same contract but is not smoked yet ([#2234](https://github.com/modelcontextprotocol/inspector/issues/2234)).
Treat them as a public contract, for the same reason as the Apps ones below:

| Attribute | Where | Meaning |
Expand All @@ -114,6 +116,10 @@ Treat them as a public contract, for the same reason as the Apps ones below:
| `data-prompt-count` | on `prompts-screen`| Entries from `prompts/list`. |
| `data-get-status` | on `prompts-screen`| `idle` → `pending` → `ok` / `error` for the current `prompts/get`. |
| `data-testid="prompt-messages"` | messages panel | The fetched prompt's **rendered** messages — the `prompts/get` counterpart of `resource-preview`, and asserted alongside `data-get-status` for the same reason. |
| `data-testid="skills-screen"` | Skills root | The element carrying the two attributes below ([#2234](https://github.com/modelcontextprotocol/inspector/issues/2234)). Not driven by `smoke-web-tabs.mjs` yet — the attributes exist so it can be, and so a rename fails in the screen's unit test rather than later. |
| `data-skill-count` | on `skills-screen` | Entries accumulated from `skills/list`. |
| `data-skill-page-count` | on `skills-screen` | Pages the last `skills/list` walk took — a **separate** fact from the count, and the one that shows pagination actually happened. |
| `data-testid="skill-manifest"` | detail pane | The selected skill's resource manifest. Absent for a `resources: "dynamic"` skill, which has no manifest to render. |

Why attributes rather than text: a smoke that waited on a label fails the next
time the label is reworded, which is noise rather than signal — and it fails as
Expand Down
26 changes: 26 additions & 0 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import type {
ResourcesPanelProps,
ServerListProps,
ShellProps,
SkillsPanelProps,
TasksPanelProps,
ToolsPanelProps,
} from "./components/views/InspectorView/types";
Expand Down Expand Up @@ -458,6 +459,11 @@ function App() {
tasks,
refreshTasks,
clearCompletedTasks,
sessionNonce,
skills,
skillsPageCount,
skillsLoadError,
refreshSkills,
subscriptions,
subscriptionStreamState,
messages,
Expand Down Expand Up @@ -828,6 +834,9 @@ function App() {
onRefreshTools,
onRefreshPrompts,
onRefreshResources,
onRefreshSkills,
onReadSkillFile,
onGetSkill,
onRefreshTasks,
onTogglePaginatedLists,
onLoadMoreTools,
Expand All @@ -851,6 +860,7 @@ function App() {
activeToolCallTaskIdRef,
clearCompletedTasks,
refreshTasks,
refreshSkills,
paginatedLists,
paginatedListsOverride,
toolsPagination,
Expand Down Expand Up @@ -1816,6 +1826,21 @@ function App() {
onRefreshApps: onRefreshTools,
};

const skillsPanelProps: SkillsPanelProps = {
// Server id AND per-connect nonce: the id alone would repeat on a
// reconnect to the same server, which is one of the crossings this key
// exists to prevent.
skillsSessionKey: `${activeServerId ?? ""}:${sessionNonce}`,
skills,
skillsPageCount,
skillsLoadError,
skillsUi: ui.skillsUi,
onSkillsUiChange: setUi.setSkillsUi,
onRefreshSkills,
onReadSkillFile,
onGetSkill,
};

const tasksPanelProps: TasksPanelProps = {
tasks,
progressByTaskId,
Expand Down Expand Up @@ -1889,6 +1914,7 @@ function App() {
prompts={promptsPanelProps}
resources={resourcesPanelProps}
apps={appsPanelProps}
skills={skillsPanelProps}
tasks={tasksPanelProps}
logs={logsPanelProps}
protocol={protocolPanelProps}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,11 +427,12 @@ describe("ConnectionInfoContent", () => {
expect(
screen.getByText("Client Advertised Extensions"),
).toBeInTheDocument();
// One row per identifier, not a comma-joined string: two ~30-character
// ids wrap mid-name in a half-width column.
expect(
screen.getByText(
"io.modelcontextprotocol/tasks, io.modelcontextprotocol/ui",
),
screen.getByText("io.modelcontextprotocol/tasks"),
).toBeInTheDocument();
expect(screen.getByText("io.modelcontextprotocol/ui")).toBeInTheDocument();
});

it("renders em-dashes for the extensions sections when neither side advertises any (#1740)", () => {
Expand All @@ -452,6 +453,73 @@ describe("ConnectionInfoContent", () => {
expect(screen.getAllByText("—")).toHaveLength(2);
});

it("hides the Skills section when the server declares no skills extension (#2234)", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
protocolEra="legacy"
/>,
);
expect(
screen.queryByText("Skills Extension Options"),
).not.toBeInTheDocument();
});

it("shows the Skills extension and its directoryRead sub-flag (#2234)", () => {
// The generic "Server Extensions" row lists the identifier; the sub-flag
// that gates `resources/directory/read` is what this section adds, and it
// is the fact a server author opens the modal to confirm.
renderWithMantine(
<ConnectionInfoContent
initializeResult={{
...fullResult,
capabilities: {
...fullResult.capabilities,
extensions: {
"io.modelcontextprotocol/skills": { directoryRead: true },
},
},
}}
clientCapabilities={fullClientCaps}
transport="streamable-http"
protocolEra="legacy"
/>,
);
expect(screen.getByText("Skills Extension Options")).toBeInTheDocument();
// Asserted on the attribute, not the copy: "Not supported" contains
// "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)", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={{
...fullResult,
capabilities: {
...fullResult.capabilities,
extensions: { "io.modelcontextprotocol/skills": {} },
},
}}
clientCapabilities={fullClientCaps}
transport="streamable-http"
protocolEra="legacy"
/>,
);
expect(screen.getByTestId("skills-directory-read")).toHaveAttribute(
"data-supported",
"false",
);
});

it("renders client registration kind when provided", () => {
renderWithMantine(
<ConnectionInfoContent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Button,
Code,
Flex,
Group,
ScrollArea,
SimpleGrid,
Stack,
Expand All @@ -18,6 +19,7 @@ import type {
} from "@modelcontextprotocol/client";
import type { ServerType } from "@inspector/core/mcp/types.js";
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
import { getSkillsExtension } from "@inspector/core/mcp/skills.js";
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
import {
CapabilityItem,
Expand Down Expand Up @@ -104,6 +106,15 @@ const SectionHeading = Title.withProps({
// `scrollable-region-focusable`).
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({
variant: "subtle",
color: "red",
Expand Down Expand Up @@ -159,16 +170,20 @@ function formatSession(
return isModernEra(era) ? "Sessionless" : "Session-based";
}

// Render an `extensions` capability map (SEP-2133) as a comma-separated list of
// its extension identifiers, or an em dash when none are present. Works for
// either side's map: the server's negotiated `capabilities.extensions` (present
// on both eras via `getServerCapabilities()`) or the Inspector's own advertised
// The extension identifiers in an `extensions` capability map (SEP-2133), one
// per rendered row, or a single em dash when none are present. Works for either
// side's map: the server's negotiated `capabilities.extensions` (present on both
// eras via `getServerCapabilities()`) or the Inspector's own advertised
// `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(
extensions: Record<string, unknown> | undefined,
): string {
): string[] {
const keys = extensions ? Object.keys(extensions) : [];
return keys.length > 0 ? keys.join(", ") : "—";
return keys.length > 0 ? keys : ["\u2014"];
}

const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [
Expand Down Expand Up @@ -259,6 +274,9 @@ export function ConnectionInfoContent({
}: ConnectionInfoContentProps) {
const { serverInfo, protocolVersion, capabilities, instructions } =
initializeResult;
// `undefined` when the server declared no Skills extension, which is what
// hides the section below — an absent extension has no sub-flags to report.
const skillsExtension = getSkillsExtension(capabilities);

// Only trust `serverInfo` when the server actually reported it; otherwise the
// name is a catalog fallback. Both rows `?.trim()` before the `||` (not `??`)
Expand Down Expand Up @@ -352,16 +370,49 @@ export function ConnectionInfoContent({
<SimpleGrid cols={2}>
<Stack gap="xs">
<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 gap="xs">
<SectionHeading>Client Advertised Extensions</SectionHeading>
<ValueText>
{formatExtensions(clientCapabilities.extensions)}
</ValueText>
{formatExtensions(clientCapabilities.extensions).map((extension) => (
<Text key={extension}>{extension}</Text>
))}
</Stack>
</SimpleGrid>

{/* Skills (SEP-2640). The "Server Extensions" row above already names the
identifier, so repeating it here would say nothing: what this section
adds is the extension's SUB-OPTIONS, which a flat list of keys cannot
show. `directoryRead` is the only one SEP-2640 defines, and whether a
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 && (
<Stack gap="xs">
<SectionHeading>Skills Extension Options</SectionHeading>
<SubOptionRow
data-testid="skills-directory-read"
data-supported={skillsExtension.directoryRead}
>
<SubOptionMark c={skillsExtension.directoryRead ? "green" : "red"}>
{skillsExtension.directoryRead ? "\u2713" : "\u2717"}
</SubOptionMark>
<Text>
Directory read — <Code>resources/directory/read</Code>
</Text>
</SubOptionRow>
</Stack>
)}

{instructions && (
<Stack gap="xs">
<SectionHeading>Server Instructions</SectionHeading>
Expand Down
Loading