fix(web): read modern tasks support from the extension map
SEP-2663 moved task support from `capabilities.tasks` to the negotiated `capabilities.extensions["io.modelcontextprotocol/tasks"]`, but the Connection Info modal's capability grid tested only for the top-level key. A modern server that advertised the extension in `server/discover` therefore showed "Tasks ✗" while the very same extension id was listed under "Server Extensions" two sections below. Server capability rows now fall back to an extension id for the capabilities the modern era expresses that way (tasks is the only one today), gated on the negotiated era so a legacy connection is still judged purely on its `initialize` capabilities. Keyed the same way `InspectorClient.isTasksExtensionNegotiated()` gates the Tasks tab, so the checkmark and the tab can't disagree. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
8dd0fdf28b
commit
73913ef09f
3 changed files with 161 additions and 2 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).
|
||||
|
|
|
|||
|
|
@ -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,94 @@ 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,
|
||||
tasks: { list: true, cancel: true },
|
||||
},
|
||||
}}
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue