Merge remote-tracking branch 'origin/v2/fix/1919-rfc6570-uri-template-expansion' into v2/fix/1919-rfc6570-uri-template-expansion

This commit is contained in:
cliffhall 2026-08-16 19:35:48 -04:00
commit 1d29e4e71c
3 changed files with 163 additions and 2 deletions

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).

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,