Merge pull request #1220 from modelcontextprotocol/v2/phase-2-roots-schema-experimental

Phase 2: Refactor Roots/Schema/Experimental cluster to MCP SDK types
This commit is contained in:
Cliff Hall 2026-04-21 14:09:15 -04:00 • committed by GitHub
commit c25a092ed1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 212 additions and 150 deletions

View file

@ -1,7 +1,7 @@
import { Alert, Button, Divider, Group, Stack, Text } from "@mantine/core"; import { Alert, Button, Divider, Group, Stack, Text } from "@mantine/core";
import type { ElicitRequestFormParams } from "@modelcontextprotocol/sdk/types.js"; import type { ElicitRequestFormParams } from "@modelcontextprotocol/sdk/types.js";
import type { JsonSchemaType } from "../../../utils/jsonUtils";
import { SchemaForm } from "../SchemaForm/SchemaForm"; import { SchemaForm } from "../SchemaForm/SchemaForm";
import type { JsonSchema } from "../SchemaForm/SchemaForm";
export interface ElicitationFormPanelProps { export interface ElicitationFormPanelProps {
request: ElicitRequestFormParams; request: ElicitRequestFormParams;
@ -38,7 +38,7 @@ export function ElicitationFormPanel({
<QuotedMessage>{formatQuoted(request.message)}</QuotedMessage> <QuotedMessage>{formatQuoted(request.message)}</QuotedMessage>
<Divider /> <Divider />
<SchemaForm <SchemaForm
schema={request.requestedSchema as JsonSchema} schema={request.requestedSchema as JsonSchemaType}
values={values} values={values}
onChange={onChange} onChange={onChange}
/> />

View file

@ -1,7 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import type {
ClientExperimentalToggle,
RequestHistoryItem,
} from "./ExperimentalFeaturesPanel";
import { ExperimentalFeaturesPanel } from "./ExperimentalFeaturesPanel"; import { ExperimentalFeaturesPanel } from "./ExperimentalFeaturesPanel";
const defaultClientToggles: ClientExperimentalToggle[] = [
{ name: "experimental/customSampling", enabled: false },
{ name: "experimental/batchRequests", enabled: true },
];
const meta: Meta<typeof ExperimentalFeaturesPanel> = { const meta: Meta<typeof ExperimentalFeaturesPanel> = {
title: "Groups/ExperimentalFeaturesPanel", title: "Groups/ExperimentalFeaturesPanel",
component: ExperimentalFeaturesPanel, component: ExperimentalFeaturesPanel,
@ -14,13 +23,10 @@ const meta: Meta<typeof ExperimentalFeaturesPanel> = {
onHeaderChange: fn(), onHeaderChange: fn(),
onCopyResponse: fn(), onCopyResponse: fn(),
onTestCapability: fn(), onTestCapability: fn(),
clientCapabilities: [ clientToggles: defaultClientToggles,
{ name: "experimental/customSampling", enabled: false },
{ name: "experimental/batchRequests", enabled: true },
],
customHeaders: [], customHeaders: [],
requestHistory: [], requestHistory: [],
requestJson: JSON.stringify( requestDraft: JSON.stringify(
{ {
jsonrpc: "2.0", jsonrpc: "2.0",
id: 1, id: 1,
@ -38,14 +44,12 @@ type Story = StoryObj<typeof ExperimentalFeaturesPanel>;
export const WithServerCaps: Story = { export const WithServerCaps: Story = {
args: { args: {
serverCapabilities: [ serverExperimental: {
{ "experimental/streaming": {
name: "experimental/streaming",
description: "Supports streaming responses for long-running operations", description: "Supports streaming responses for long-running operations",
methods: ["experimental/stream.start", "experimental/stream.cancel"], methods: ["experimental/stream.start", "experimental/stream.cancel"],
}, },
{ "experimental/caching": {
name: "experimental/caching",
description: "Server-side response caching with TTL support", description: "Server-side response caching with TTL support",
methods: [ methods: [
"experimental/cache.get", "experimental/cache.get",
@ -53,37 +57,31 @@ export const WithServerCaps: Story = {
"experimental/cache.clear", "experimental/cache.clear",
], ],
}, },
], },
}, },
}; };
export const NoServerCaps: Story = { export const NoServerCaps: Story = {
args: { args: {
serverCapabilities: [], serverExperimental: undefined,
}, },
}; };
export const WithResponse: Story = { export const WithResponse: Story = {
args: { args: {
serverCapabilities: [ serverExperimental: {
{ "experimental/echo": {
name: "experimental/echo",
description: "Echoes back the request for testing", description: "Echoes back the request for testing",
methods: ["experimental/echo"],
}, },
], },
responseJson: JSON.stringify( response: {
{ jsonrpc: "2.0" as const,
jsonrpc: "2.0", id: 1,
id: 1, result: {
result: { echo: "Hello from experimental endpoint",
echo: "Hello from experimental endpoint", timestamp: "2026-03-17T10:30:00Z",
timestamp: "2026-03-17T10:30:00Z",
},
}, },
null, },
2,
),
customHeaders: [ customHeaders: [
{ key: "X-Custom-Auth", value: "Bearer token123" }, { key: "X-Custom-Auth", value: "Bearer token123" },
{ key: "X-Request-Id", value: "req-abc-456" }, { key: "X-Request-Id", value: "req-abc-456" },
@ -91,33 +89,50 @@ export const WithResponse: Story = {
}, },
}; };
export const WithErrorResponse: Story = {
args: {
serverExperimental: {
"experimental/echo": {
description: "Echoes back the request for testing",
},
},
response: {
jsonrpc: "2.0" as const,
id: 1,
error: {
code: -32601,
message: "Method not found",
},
},
},
};
export const WithHistory: Story = { export const WithHistory: Story = {
args: { args: {
serverCapabilities: [ serverExperimental: {
{ "experimental/metrics": {
name: "experimental/metrics",
description: "Exposes server metrics", description: "Exposes server metrics",
}, },
], },
requestHistory: [ requestHistory: [
{ {
timestamp: "2026-03-17 10:30:15", timestamp: new Date("2026-03-17T10:30:15Z"),
method: "experimental/metrics.get", method: "experimental/metrics.get",
status: "success", status: "success",
durationMs: 42, durationMs: 42,
}, },
{ {
timestamp: "2026-03-17 10:29:50", timestamp: new Date("2026-03-17T10:29:50Z"),
method: "experimental/echo", method: "experimental/echo",
status: "success", status: "success",
durationMs: 15, durationMs: 15,
}, },
{ {
timestamp: "2026-03-17 10:28:30", timestamp: new Date("2026-03-17T10:28:30Z"),
method: "experimental/unknown", method: "experimental/unknown",
status: "error", status: "error",
durationMs: 120, durationMs: 120,
}, },
], ] satisfies RequestHistoryItem[],
}, },
}; };

View file

@ -1,6 +1,7 @@
import { import {
ActionIcon, ActionIcon,
Alert, Alert,
Badge,
Button, Button,
Card, Card,
Checkbox, Checkbox,
@ -13,36 +14,35 @@ import {
Textarea, Textarea,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import type {
JSONRPCErrorResponse,
JSONRPCResponse,
ServerCapabilities,
} from "@modelcontextprotocol/sdk/types.js";
export interface ExperimentalCapability { export interface ClientExperimentalToggle {
name: string;
description?: string;
methods?: string[];
}
export interface ClientExperimentalCapability {
name: string; name: string;
enabled: boolean; enabled: boolean;
} }
export interface KeyValuePair { export interface HeaderPair {
key: string; key: string;
value: string; value: string;
} }
export interface RequestHistoryItem { export interface RequestHistoryItem {
timestamp: string; timestamp: Date;
method: string; method: string;
status: string; status: string;
durationMs: number; durationMs: number;
} }
export interface ExperimentalFeaturesPanelProps { export interface ExperimentalFeaturesPanelProps {
serverCapabilities: ExperimentalCapability[]; serverExperimental: ServerCapabilities["experimental"];
clientCapabilities: ClientExperimentalCapability[]; clientToggles: ClientExperimentalToggle[];
requestJson: string; requestDraft: string;
responseJson?: string; response?: JSONRPCResponse | JSONRPCErrorResponse;
customHeaders: KeyValuePair[]; customHeaders: HeaderPair[];
requestHistory: RequestHistoryItem[]; requestHistory: RequestHistoryItem[];
onToggleClientCapability: (name: string, enabled: boolean) => void; onToggleClientCapability: (name: string, enabled: boolean) => void;
onRequestChange: (json: string) => void; onRequestChange: (json: string) => void;
@ -79,19 +79,60 @@ const RemoveIcon = ActionIcon.withProps({
color: "red", color: "red",
}); });
function formatMethods(methods: string[]): string {
return `Methods: ${methods.join(", ")}`;
}
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
return `${ms}ms`; return `${ms}ms`;
} }
function formatTimestamp(date: Date): string {
return date.toLocaleString();
}
function formatResponse(
response: JSONRPCResponse | JSONRPCErrorResponse,
): string {
return JSON.stringify(response, null, 2);
}
function isErrorResponse(
response: JSONRPCResponse | JSONRPCErrorResponse,
): response is JSONRPCErrorResponse {
return "error" in response;
}
function getCapabilityEntries(
experimental: ServerCapabilities["experimental"],
): [string, object][] {
if (!experimental) return [];
return Object.entries(experimental);
}
function getCapabilityDescription(value: object): string | undefined {
if ("description" in value && typeof value.description === "string") {
return value.description;
}
return undefined;
}
function getCapabilityMethods(value: object): string[] | undefined {
if (
"methods" in value &&
Array.isArray(value.methods) &&
value.methods.every((m: unknown) => typeof m === "string")
) {
return value.methods as string[];
}
return undefined;
}
function formatMethods(methods: string[]): string {
return `Methods: ${methods.join(", ")}`;
}
export function ExperimentalFeaturesPanel({ export function ExperimentalFeaturesPanel({
serverCapabilities, serverExperimental,
clientCapabilities, clientToggles,
requestJson, requestDraft,
responseJson, response,
customHeaders, customHeaders,
requestHistory, requestHistory,
onToggleClientCapability, onToggleClientCapability,
@ -103,6 +144,8 @@ export function ExperimentalFeaturesPanel({
onCopyResponse, onCopyResponse,
onTestCapability, onTestCapability,
}: ExperimentalFeaturesPanelProps) { }: ExperimentalFeaturesPanelProps) {
const serverEntries = getCapabilityEntries(serverExperimental);
return ( return (
<Stack gap="md"> <Stack gap="md">
<Alert color="yellow"> <Alert color="yellow">
@ -111,38 +154,42 @@ export function ExperimentalFeaturesPanel({
<Title order={5}>Server Experimental Capabilities:</Title> <Title order={5}>Server Experimental Capabilities:</Title>
{serverCapabilities.length === 0 ? ( {serverEntries.length === 0 ? (
<Text c="dimmed">No experimental capabilities</Text> <Text c="dimmed">No experimental capabilities</Text>
) : ( ) : (
serverCapabilities.map((cap) => ( serverEntries.map(([name, value]) => {
<CapCard key={cap.name}> const description = getCapabilityDescription(value);
<Stack gap="xs"> const methods = getCapabilityMethods(value);
<Text fw={600}>{cap.name}</Text> return (
{cap.description && <HintText>{cap.description}</HintText>} <CapCard key={name}>
{cap.methods && cap.methods.length > 0 && ( <Stack gap="xs">
<MetaText>{formatMethods(cap.methods)}</MetaText> <Text fw={600}>{name}</Text>
)} {description && <HintText>{description}</HintText>}
<Group> {methods && methods.length > 0 && (
<CompactButton onClick={() => onTestCapability(cap.name)}> <MetaText>{formatMethods(methods)}</MetaText>
Test → )}
</CompactButton> <Group>
</Group> <CompactButton onClick={() => onTestCapability(name)}>
</Stack> Test →
</CapCard> </CompactButton>
)) </Group>
</Stack>
</CapCard>
);
})
)} )}
<Divider /> <Divider />
<Title order={5}>Client Experimental Capabilities:</Title> <Title order={5}>Client Experimental Capabilities:</Title>
{clientCapabilities.map((clientCap) => ( {clientToggles.map((toggle) => (
<Checkbox <Checkbox
key={clientCap.name} key={toggle.name}
label={clientCap.name} label={toggle.name}
checked={clientCap.enabled} checked={toggle.enabled}
onChange={(e) => onChange={(e) =>
onToggleClientCapability(clientCap.name, e.currentTarget.checked) onToggleClientCapability(toggle.name, e.currentTarget.checked)
} }
/> />
))} ))}
@ -182,7 +229,7 @@ export function ExperimentalFeaturesPanel({
<Textarea <Textarea
label="Request" label="Request"
ff="monospace" ff="monospace"
value={requestJson} value={requestDraft}
onChange={(e) => onRequestChange(e.currentTarget.value)} onChange={(e) => onRequestChange(e.currentTarget.value)}
autosize autosize
minRows={6} minRows={6}
@ -190,12 +237,21 @@ export function ExperimentalFeaturesPanel({
<Button onClick={onSendRequest}>Send Request</Button> <Button onClick={onSendRequest}>Send Request</Button>
{responseJson && ( {response && (
<> <>
<Group gap="xs">
<Text fw={500} size="sm">
Response
</Text>
{isErrorResponse(response) && (
<Badge color="red" size="sm">
Error
</Badge>
)}
</Group>
<Textarea <Textarea
label="Response"
ff="monospace" ff="monospace"
value={responseJson} value={formatResponse(response)}
readOnly readOnly
autosize autosize
minRows={4} minRows={4}
@ -221,7 +277,7 @@ export function ExperimentalFeaturesPanel({
<Table.Tbody> <Table.Tbody>
{requestHistory.map((item, index) => ( {requestHistory.map((item, index) => (
<Table.Tr key={index}> <Table.Tr key={index}>
<Table.Td>{item.timestamp}</Table.Td> <Table.Td>{formatTimestamp(item.timestamp)}</Table.Td>
<Table.Td>{item.method}</Table.Td> <Table.Td>{item.method}</Table.Td>
<Table.Td>{item.status}</Table.Td> <Table.Td>{item.status}</Table.Td>
<Table.Td>{formatDuration(item.durationMs)}</Table.Td> <Table.Td>{formatDuration(item.durationMs)}</Table.Td>

View file

@ -12,8 +12,8 @@ import type {
ElicitRequest, ElicitRequest,
ElicitRequestFormParams, ElicitRequestFormParams,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import type { JsonSchemaType } from "../../../utils/jsonUtils";
import { SchemaForm } from "../SchemaForm/SchemaForm"; import { SchemaForm } from "../SchemaForm/SchemaForm";
import type { JsonSchema } from "../SchemaForm/SchemaForm";
export interface InlineElicitationRequestProps { export interface InlineElicitationRequestProps {
request: ElicitRequest["params"]; request: ElicitRequest["params"];
@ -89,7 +89,7 @@ export function InlineElicitationRequest({
{isFormMode(request) && ( {isFormMode(request) && (
<SchemaForm <SchemaForm
schema={request.requestedSchema as JsonSchema} schema={request.requestedSchema as JsonSchemaType}
values={values ?? {}} values={values ?? {}}
onChange={onChange} onChange={onChange}
/> />

View file

@ -1,14 +1,20 @@
import type { Root } from "@modelcontextprotocol/sdk/types.js";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { RootsTable } from "./RootsTable"; import { RootsTable } from "./RootsTable";
const sampleRoots: Root[] = [
{ name: "Project Source", uri: "file:///home/user/project/src" },
{ name: "Configuration", uri: "file:///home/user/project/config" },
{ name: "Documentation", uri: "file:///home/user/project/docs" },
];
const meta: Meta<typeof RootsTable> = { const meta: Meta<typeof RootsTable> = {
title: "Groups/RootsTable", title: "Groups/RootsTable",
component: RootsTable, component: RootsTable,
args: { args: {
onRemoveRoot: fn(), onRemoveRoot: fn(),
onNewRootNameChange: fn(), onNewRootDraftChange: fn(),
onNewRootPathChange: fn(),
onAddRoot: fn(), onAddRoot: fn(),
onBrowse: fn(), onBrowse: fn(),
}, },
@ -19,29 +25,25 @@ type Story = StoryObj<typeof RootsTable>;
export const WithRoots: Story = { export const WithRoots: Story = {
args: { args: {
roots: [ roots: sampleRoots,
{ name: "Project Source", uri: "file:///home/user/project/src" }, newRootDraft: { name: "", uri: "" },
{ name: "Configuration", uri: "file:///home/user/project/config" },
{ name: "Documentation", uri: "file:///home/user/project/docs" },
],
newRootName: "",
newRootPath: "",
}, },
}; };
export const Empty: Story = { export const Empty: Story = {
args: { args: {
roots: [], roots: [],
newRootName: "", newRootDraft: { name: "", uri: "" },
newRootPath: "",
}, },
}; };
export const AddingNew: Story = { export const AddingNew: Story = {
args: { args: {
roots: [{ name: "Project Source", uri: "file:///home/user/project/src" }], roots: [sampleRoots[0]],
newRootName: "Test Data", newRootDraft: {
newRootPath: "/home/user/project/test-data", name: "Test Data",
uri: "file:///home/user/project/test-data",
},
}, },
}; };
@ -51,11 +53,13 @@ export const ManyRoots: Story = {
{ name: "Source Code", uri: "file:///home/user/project/src" }, { name: "Source Code", uri: "file:///home/user/project/src" },
{ name: "Configuration", uri: "file:///home/user/project/config" }, { name: "Configuration", uri: "file:///home/user/project/config" },
{ name: "Documentation", uri: "file:///home/user/project/docs" }, { name: "Documentation", uri: "file:///home/user/project/docs" },
{ name: "Test Fixtures", uri: "file:///home/user/project/test/fixtures" }, {
name: "Test Fixtures",
uri: "file:///home/user/project/test/fixtures",
},
{ name: "Build Output", uri: "file:///home/user/project/dist" }, { name: "Build Output", uri: "file:///home/user/project/dist" },
{ name: "Scripts", uri: "file:///home/user/project/scripts" }, { name: "Scripts", uri: "file:///home/user/project/scripts" },
], ],
newRootName: "", newRootDraft: { name: "", uri: "" },
newRootPath: "",
}, },
}; };

View file

@ -10,19 +10,15 @@ import {
TextInput, TextInput,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import type { Root } from "@modelcontextprotocol/sdk/types.js";
export interface RootEntry { export type RootDraft = { name: string; uri: string };
name: string;
uri: string;
}
export interface RootsTableProps { export interface RootsTableProps {
roots: RootEntry[]; roots: Root[];
newRootName: string; newRootDraft: RootDraft;
newRootPath: string;
onRemoveRoot: (uri: string) => void; onRemoveRoot: (uri: string) => void;
onNewRootNameChange: (name: string) => void; onNewRootDraftChange: (draft: RootDraft) => void;
onNewRootPathChange: (path: string) => void;
onAddRoot: () => void; onAddRoot: () => void;
onBrowse: () => void; onBrowse: () => void;
} }
@ -44,11 +40,9 @@ const AddRootButton = Button.withProps({
export function RootsTable({ export function RootsTable({
roots, roots,
newRootName, newRootDraft,
newRootPath,
onRemoveRoot, onRemoveRoot,
onNewRootNameChange, onNewRootDraftChange,
onNewRootPathChange,
onAddRoot, onAddRoot,
onBrowse, onBrowse,
}: RootsTableProps) { }: RootsTableProps) {
@ -89,13 +83,23 @@ export function RootsTable({
<Title order={5}>Add New Root:</Title> <Title order={5}>Add New Root:</Title>
<TextInput <TextInput
label="Name" label="Name"
value={newRootName} value={newRootDraft.name}
onChange={(e) => onNewRootNameChange(e.currentTarget.value)} onChange={(e) =>
onNewRootDraftChange({
...newRootDraft,
name: e.currentTarget.value,
})
}
/> />
<TextInput <TextInput
label="Path" label="URI"
value={newRootPath} value={newRootDraft.uri}
onChange={(e) => onNewRootPathChange(e.currentTarget.value)} onChange={(e) =>
onNewRootDraftChange({
...newRootDraft,
uri: e.currentTarget.value,
})
}
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="light" onClick={onBrowse}> <Button variant="light" onClick={onBrowse}>

View file

@ -8,24 +8,7 @@ import {
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import type { JsonSchemaType } from "../../../utils/jsonUtils";
export interface JsonSchema {
type?: string;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
enum?: string[];
oneOf?: Array<{ const: string; title?: string }>;
anyOf?: Array<{ const: string; title?: string }>;
default?: unknown;
description?: string;
title?: string;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
format?: string;
}
const FieldLabel = Text.withProps({ const FieldLabel = Text.withProps({
fw: 500, fw: 500,
@ -42,20 +25,20 @@ function serializeJson(value: unknown): string {
} }
export interface SchemaFormProps { export interface SchemaFormProps {
schema: JsonSchema; schema: JsonSchemaType;
values: Record<string, unknown>; values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void; onChange: (values: Record<string, unknown>) => void;
disabled?: boolean; disabled?: boolean;
} }
function getDefaultValue(fieldSchema: JsonSchema): unknown { function getDefaultValue(fieldSchema: JsonSchemaType): unknown {
if (fieldSchema.default !== undefined) { if (fieldSchema.default !== undefined) {
return fieldSchema.default; return fieldSchema.default;
} }
return undefined; return undefined;
} }
function resolveValue(value: unknown, fieldSchema: JsonSchema): unknown { function resolveValue(value: unknown, fieldSchema: JsonSchemaType): unknown {
if (value !== undefined) { if (value !== undefined) {
return value; return value;
} }
@ -75,7 +58,7 @@ export function SchemaForm({
onChange({ ...values, [fieldName]: fieldValue }); onChange({ ...values, [fieldName]: fieldValue });
} }
function renderField(fieldName: string, fieldSchema: JsonSchema) { function renderField(fieldName: string, fieldSchema: JsonSchemaType) {
const isRequired = requiredFields.includes(fieldName); const isRequired = requiredFields.includes(fieldName);
const label = fieldSchema.title ?? fieldName; const label = fieldSchema.title ?? fieldName;
const description = fieldSchema.description; const description = fieldSchema.description;
@ -100,8 +83,8 @@ export function SchemaForm({
// string with oneOf // string with oneOf
if (fieldSchema.type === "string" && fieldSchema.oneOf) { if (fieldSchema.type === "string" && fieldSchema.oneOf) {
const data = fieldSchema.oneOf.map((item) => ({ const data = fieldSchema.oneOf.map((item) => ({
value: item.const, value: String(item.const ?? ""),
label: item.title ?? item.const, label: item.title ?? String(item.const ?? ""),
})); }));
return ( return (
<Select <Select
@ -175,8 +158,8 @@ export function SchemaForm({
// array with items having anyOf // array with items having anyOf
if (fieldSchema.type === "array" && fieldSchema.items?.anyOf) { if (fieldSchema.type === "array" && fieldSchema.items?.anyOf) {
const data = fieldSchema.items.anyOf.map((item) => ({ const data = fieldSchema.items.anyOf.map((item) => ({
value: item.const, value: String(item.const ?? ""),
label: item.title ?? item.const, label: item.title ?? String(item.const ?? ""),
})); }));
return ( return (
<MultiSelect <MultiSelect