From 39e1c67401370406ca1b3dab72140f4981c991f7 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 28 Apr 2026 12:33:08 -0400 Subject: [PATCH] feat(experimental): derive client toggles from ClientCapabilities (#1252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../ExperimentalFeaturesPanel.stories.tsx | 38 +++++++--- .../ExperimentalFeaturesPanel.tsx | 70 ++++++++++++++----- 2 files changed, 81 insertions(+), 27 deletions(-) diff --git a/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.stories.tsx b/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.stories.tsx index 37b133f5..1fddc9eb 100644 --- a/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.stories.tsx +++ b/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.stories.tsx @@ -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 = { title: "Groups/ExperimentalFeaturesPanel", component: ExperimentalFeaturesPanel, @@ -23,7 +15,9 @@ const meta: Meta = { 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: { diff --git a/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx b/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx index 41406a27..81a58507 100644 --- a/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx +++ b/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx @@ -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({ Client Experimental Capabilities: - {clientToggles.map((toggle) => ( - - onToggleClientCapability(toggle.name, e.currentTarget.checked) - } - /> - ))} + {getClientToggleNames(clientExperimental).map((name) => { + const metadata = CLIENT_EXPERIMENTAL_TOGGLE_METADATA[name]; + const enabled = clientExperimental?.[name] !== undefined; + return ( + + + onToggleClientCapability(name, e.currentTarget.checked) + } + /> + {metadata?.description && ( + {metadata.description} + )} + + ); + })}