feat(experimental): derive client toggles from ClientCapabilities (#1252)

* feat(experimental): derive client toggles from ClientCapabilities (#1240)

Closes the Phase 5 audit partial: ExperimentalFeaturesPanel now accepts
clientExperimental: ClientCapabilities['experimental'] (the SDK record
shape) instead of a parallel clientToggles array of UI metadata.

Adopts Option B from the design question: toggle metadata (label,
description) lives in a static CLIENT_EXPERIMENTAL_TOGGLE_METADATA map
inside the component file. Iteration is over Object.keys of the live
record, so presence-in-record means enabled. Unknown keys (in record but
not in the metadata map) render with the raw key as label and no
description, per AC.

Renders one Checkbox per active toggle plus an indented HintText
description when metadata is known. Callback signature unchanged.

Stories cover empty, all-known, and mixed-known-and-unknown cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Render full toggle universe, derive checked from record

Address in-review feedback: with the strict-AC iteration over
Object.keys(clientExperimental), unchecking a toggle made its row
disappear (no schema slot for "disabled but available"), so the user
couldn't re-enable a toggle they just turned off.

Refines Option B: render the union of metadata-map keys and record keys.
Each row's checked state is derived from name-in-record. Result: known
toggles always render, regardless of record state; toggling is fully
reversible from the UI; unknown record keys still surface with a raw
label.

Story rename: NoClientToggles → NoneEnabled, AllKnownClientToggles →
AllKnownEnabled, MixedKnownAndUnknownToggles → MixedKnownAndUnknown —
the new names describe state, not record shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-04-28 12:33:08 -04:00 • committed by GitHub
commit 39e1c67401
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 81 additions and 27 deletions

View file

@ -1,16 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import type {
ClientExperimentalToggle,
RequestHistoryItem,
} from "./ExperimentalFeaturesPanel";
import type { RequestHistoryItem } from "./ExperimentalFeaturesPanel";
import { ExperimentalFeaturesPanel } from "./ExperimentalFeaturesPanel";
const defaultClientToggles: ClientExperimentalToggle[] = [
{ name: "experimental/customSampling", enabled: false },
{ name: "experimental/batchRequests", enabled: true },
];
const meta: Meta<typeof ExperimentalFeaturesPanel> = {
title: "Groups/ExperimentalFeaturesPanel",
component: ExperimentalFeaturesPanel,
@ -23,7 +15,9 @@ const meta: Meta<typeof ExperimentalFeaturesPanel> = {
onHeaderChange: fn(),
onCopyResponse: fn(),
onTestCapability: fn(),
clientToggles: defaultClientToggles,
clientExperimental: {
"experimental/batchRequests": {},
},
customHeaders: [],
requestHistory: [],
requestDraft: JSON.stringify(
@ -67,6 +61,30 @@ export const NoServerCaps: Story = {
},
};
export const NoneEnabled: Story = {
args: {
clientExperimental: undefined,
},
};
export const AllKnownEnabled: Story = {
args: {
clientExperimental: {
"experimental/customSampling": {},
"experimental/batchRequests": {},
},
},
};
export const MixedKnownAndUnknown: Story = {
args: {
clientExperimental: {
"experimental/customSampling": {},
"experimental/futureFeatureXyz": {},
},
},
};
export const WithResponse: Story = {
args: {
serverExperimental: {

View file

@ -15,16 +15,12 @@ import {
Title,
} from "@mantine/core";
import type {
ClientCapabilities,
JSONRPCErrorResponse,
JSONRPCResponse,
ServerCapabilities,
} from "@modelcontextprotocol/sdk/types.js";
export interface ClientExperimentalToggle {
name: string;
enabled: boolean;
}
export interface HeaderPair {
key: string;
value: string;
@ -39,7 +35,7 @@ export interface RequestHistoryItem {
export interface ExperimentalFeaturesPanelProps {
serverExperimental: ServerCapabilities["experimental"];
clientToggles: ClientExperimentalToggle[];
clientExperimental: ClientCapabilities["experimental"];
requestDraft: string;
response?: JSONRPCResponse | JSONRPCErrorResponse;
customHeaders: HeaderPair[];
@ -54,6 +50,27 @@ export interface ExperimentalFeaturesPanelProps {
onTestCapability: (name: string) => void;
}
interface ClientToggleMetadata {
label: string;
description: string;
}
const CLIENT_EXPERIMENTAL_TOGGLE_METADATA: Record<
string,
ClientToggleMetadata
> = {
"experimental/customSampling": {
label: "Custom sampling",
description:
"Allow servers to invoke client-defined sampling strategies via experimental/sampling.* methods.",
},
"experimental/batchRequests": {
label: "Batch requests",
description:
"Send multiple JSON-RPC requests in a single call and receive a batched response.",
},
};
const HintText = Text.withProps({
size: "sm",
c: "dimmed",
@ -128,9 +145,20 @@ function formatMethods(methods: string[]): string {
return `Methods: ${methods.join(", ")}`;
}
function getClientToggleNames(
clientExperimental: ClientCapabilities["experimental"],
): string[] {
const known = Object.keys(CLIENT_EXPERIMENTAL_TOGGLE_METADATA);
const recordKeys = Object.keys(clientExperimental ?? {});
const unknown = recordKeys.filter(
(name) => !(name in CLIENT_EXPERIMENTAL_TOGGLE_METADATA),
);
return [...known, ...unknown];
}
export function ExperimentalFeaturesPanel({
serverExperimental,
clientToggles,
clientExperimental,
requestDraft,
response,
customHeaders,
@ -183,16 +211,24 @@ export function ExperimentalFeaturesPanel({
<Title order={5}>Client Experimental Capabilities:</Title>
{clientToggles.map((toggle) => (
<Checkbox
key={toggle.name}
label={toggle.name}
checked={toggle.enabled}
onChange={(e) =>
onToggleClientCapability(toggle.name, e.currentTarget.checked)
}
/>
))}
{getClientToggleNames(clientExperimental).map((name) => {
const metadata = CLIENT_EXPERIMENTAL_TOGGLE_METADATA[name];
const enabled = clientExperimental?.[name] !== undefined;
return (
<Stack key={name} gap={4}>
<Checkbox
label={metadata?.label ?? name}
checked={enabled}
onChange={(e) =>
onToggleClientCapability(name, e.currentTarget.checked)
}
/>
{metadata?.description && (
<HintText pl="xl">{metadata.description}</HintText>
)}
</Stack>
);
})}
<Divider />