Migrate all three clients + core + test-servers from
@modelcontextprotocol/sdk@1.29 to SDK v2 (2.0.0-beta.4), keeping behavior
identical against legacy (2025-11-25) servers. Foundation for the SDK V2 +
New Spec stack.
Packages
- Split @modelcontextprotocol/sdk -> @modelcontextprotocol/client (Client,
transports, OAuth, error taxonomy, protocol types) + core (Zod schemas);
stdio -> client/stdio, Ajv -> client/validators/ajv. Test-servers ->
@modelcontextprotocol/server (+ server-legacy/sse). Deterministic import
rewrite (schemas -> core, everything else -> client); the codemod misroutes
client code to /server so it was not used for path routing.
- new Client(..., { versionNegotiation: { mode: 'legacy' } }) — nothing
2026-era goes on the wire.
Runtime (core/mcp/inspectorClient.ts)
- Error taxonomy: McpError -> ProtocolError, ErrorCode -> ProtocolErrorCode;
RequestTimeout -> SdkError/SdkErrorCode.
- Handlers take method strings; task methods (tasks/*, notifications/tasks/*)
use the 3-arg custom form (excluded from v2's spec-method set).
- Legacy tasks kept working (per project requirement): requestor tasks re-
implemented over raw client.request + explicit deprecated schemas;
experimental.tasks.callToolStream replaced by a local pollTaskToolCall
async generator (with _progressHandlers revival so task-execution progress
still reaches the caller). Receiver (server-initiated) tasks: the v2 Client
validates a sampling/elicit handler result and rejects the legacy { task }
response with -32602 — installReceiverTaskResponseBypass swaps the wrapped
entry in the private _requestHandlers map for the task-augmented branch so
{ task } reaches the wire (fixes server-initiated sampling-via-task).
- Pagination: list verbs use raw client.request({method}, List*ResultSchema)
for single-page behavior (v2's client.listX() auto-aggregate all pages).
Web / auth / apps
- Rename local JsonSchemaType -> InspectorFormSchema + add toFormSchema()
structural narrowing at the SDK-schema -> form boundary.
- ext-apps still peers on SDK v1: one compat boundary with documented casts
(remove when ext-apps#702 lands a v2 peer).
- OAuth provider saveClientInformation reconciled with v2's
OAuthClientInformationContext; InvalidClientMetadataError re-homed as a
local class (server-legacy/auth is not browser-safe).
- vitest.shared.mts dedupes @modelcontextprotocol/client+core to one copy so
vi.mock intercepts the SUT's import.
Accepted v2 behavior changes (per #1624 / spec §9.2): unknown-tool call
rejects with -32602; cancellation is stream-abort (no guaranteed
notifications/cancelled frame); output-schema strictness restored via the
Inspector's own Ajv check on the default path.
npm run ci green: validate (web 3087 / cli 212 / tui 274 / launcher 5),
coverage (>=90 per-file, all clients), smoke (launcher/cli/web), storybook
(410).
Closes #1624
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
279 lines
8.4 KiB
TypeScript
279 lines
8.4 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Button,
|
|
Collapse,
|
|
Divider,
|
|
Group,
|
|
Image,
|
|
ScrollArea,
|
|
Stack,
|
|
Switch,
|
|
Text,
|
|
} from "@mantine/core";
|
|
import { useId, useState } from "react";
|
|
import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri";
|
|
import type {
|
|
ProgressNotification,
|
|
Tool,
|
|
ToolAnnotations,
|
|
} from "@modelcontextprotocol/client";
|
|
import { resolveDisplayLabel } from "../../../utils/toolUtils";
|
|
import { toFormSchema } from "../../../utils/jsonUtils";
|
|
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
|
|
import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay";
|
|
import { SchemaForm } from "../SchemaForm/SchemaForm";
|
|
|
|
export type ToolProgress = Pick<
|
|
ProgressNotification["params"],
|
|
"progress" | "total" | "message"
|
|
>;
|
|
|
|
export interface ToolDetailPanelProps {
|
|
tool: Tool;
|
|
formValues: Record<string, unknown>;
|
|
isExecuting: boolean;
|
|
progress?: ToolProgress;
|
|
/** Whether the connected server advertises task-augmented tool calls. */
|
|
serverSupportsTaskToolCalls: boolean;
|
|
/** User's "Run as task" preference (only meaningful for `optional` tools). */
|
|
runAsTask: boolean;
|
|
onRunAsTaskChange: (value: boolean) => void;
|
|
onFormChange: (values: Record<string, unknown>) => void;
|
|
/** Receives the effective run-as-task decision for this execution. */
|
|
onExecute: (runAsTask: boolean) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
// Outer column: title/annotations pin at top, the Execute footer pins at the
|
|
// bottom, and the middle (description + form + progress) scrolls when the
|
|
// enclosing card hits its `mah`. `mih: 0` lets the flex children shrink.
|
|
const PanelStack = Stack.withProps({
|
|
gap: "md",
|
|
miw: 0,
|
|
mih: 0,
|
|
});
|
|
|
|
const PinnedHeader = Stack.withProps({
|
|
gap: "md",
|
|
flex: "0 0 auto",
|
|
});
|
|
|
|
// `0 1 auto` + `mih: 0`: shrinks to the available space and scrolls; a short
|
|
// form doesn't reserve extra height, keeping Execute snug below it.
|
|
const BodyScroll = ScrollArea.withProps({
|
|
flex: "0 1 auto",
|
|
miw: 0,
|
|
mih: 0,
|
|
type: "auto",
|
|
scrollbars: "y",
|
|
offsetScrollbars: true,
|
|
});
|
|
|
|
const BodyStack = Stack.withProps({
|
|
gap: "md",
|
|
});
|
|
|
|
// Left-aligned (Execute first, Cancel after) so the primary action sits closest
|
|
// to the sidebar controls / the form fields above — shortest pointer travel.
|
|
const FooterRow = Group.withProps({
|
|
justify: "flex-start",
|
|
flex: "0 0 auto",
|
|
});
|
|
|
|
const TitleRow = Group.withProps({
|
|
gap: "sm",
|
|
wrap: "nowrap",
|
|
align: "center",
|
|
miw: 0,
|
|
});
|
|
|
|
const ToolIcon = Image.withProps({
|
|
w: 24,
|
|
h: 24,
|
|
fit: "contain",
|
|
});
|
|
|
|
// `flex: 1` lets the title absorb the row's slack so the chevron toggle pins
|
|
// to the right edge of the (nowrap) TitleRow.
|
|
const ToolTitle = Text.withProps({
|
|
fw: 700,
|
|
size: "lg",
|
|
truncate: "end",
|
|
flex: 1,
|
|
});
|
|
|
|
// Chevron toggle for the collapsible description, pinned to the right of the
|
|
// title row. `aria-label` is set per-render since it reflects the open state.
|
|
const DescriptionToggle = ActionIcon.withProps({
|
|
variant: "subtle",
|
|
color: "gray",
|
|
size: "sm",
|
|
});
|
|
|
|
const DescriptionText = Text.withProps({
|
|
size: "sm",
|
|
c: "dimmed",
|
|
});
|
|
|
|
const CancelButton = Button.withProps({
|
|
variant: "subtle",
|
|
color: "red",
|
|
});
|
|
|
|
// Left-aligned row hosting the "Run as task" toggle, above the execute footer.
|
|
const TaskToggleRow = Group.withProps({
|
|
justify: "flex-start",
|
|
flex: "0 0 auto",
|
|
});
|
|
|
|
const RunAsTaskSwitch = Switch.withProps({
|
|
size: "sm",
|
|
label: "Run as task",
|
|
});
|
|
|
|
// A tool's per-tool task support, defaulting to "forbidden" (the SDK default
|
|
// when `execution` is absent) so tools that say nothing can't be run as tasks.
|
|
type TaskSupport = "forbidden" | "optional" | "required";
|
|
function getTaskSupport(tool: Tool): TaskSupport {
|
|
return tool.execution?.taskSupport ?? "forbidden";
|
|
}
|
|
|
|
function hasAnyAnnotation(annotations?: ToolAnnotations): boolean {
|
|
return !!(
|
|
annotations &&
|
|
(annotations.readOnlyHint ||
|
|
annotations.destructiveHint ||
|
|
annotations.idempotentHint ||
|
|
annotations.openWorldHint)
|
|
);
|
|
}
|
|
|
|
export function ToolDetailPanel({
|
|
tool,
|
|
formValues,
|
|
isExecuting,
|
|
progress,
|
|
serverSupportsTaskToolCalls,
|
|
runAsTask,
|
|
onRunAsTaskChange,
|
|
onFormChange,
|
|
onExecute,
|
|
onCancel,
|
|
}: ToolDetailPanelProps) {
|
|
const { name, title, description, icons, annotations, inputSchema } = tool;
|
|
// Narrow the SDK protocol schema to the form renderer's schema type.
|
|
const formSchema = toFormSchema(inputSchema) ?? {};
|
|
const iconSrc = icons?.[0]?.src;
|
|
|
|
// Descriptions are shown by default (most are short); the chevron lets the
|
|
// user hide a long one to keep the form and Execute footer in view. Reset to
|
|
// shown when switching tools (React's adjust-state-during-render pattern) so
|
|
// a prior tool's hidden state doesn't carry over — mirrors how ToolsScreen
|
|
// clears formValues on change.
|
|
const [descriptionOpen, setDescriptionOpen] = useState(true);
|
|
const [prevToolName, setPrevToolName] = useState(name);
|
|
if (name !== prevToolName) {
|
|
setPrevToolName(name);
|
|
setDescriptionOpen(true);
|
|
}
|
|
// Ties the toggle to the Collapse region so assistive tech announces it as a
|
|
// single expandable control (aria-expanded + aria-controls).
|
|
const descriptionRegionId = useId();
|
|
|
|
// Show the toggle only when the server supports task tool calls and the tool
|
|
// doesn't forbid them. `required` tools are forced on (checked + disabled);
|
|
// `optional` tools follow the user's `runAsTask` choice.
|
|
const taskSupport = getTaskSupport(tool);
|
|
const showRunAsTask =
|
|
serverSupportsTaskToolCalls && taskSupport !== "forbidden";
|
|
// Gate the effective decision on `showRunAsTask` (which includes
|
|
// `serverSupportsTaskToolCalls`): a stale `runAsTask`/`required` value must
|
|
// not route through callToolStream when the toggle is hidden because the
|
|
// server doesn't advertise task tool calls. Per spec, a tool's taskSupport is
|
|
// only considered when the server advertises `tasks.requests.tools.call`.
|
|
const effectiveRunAsTask =
|
|
showRunAsTask &&
|
|
(taskSupport === "required" || (taskSupport === "optional" && runAsTask));
|
|
|
|
return (
|
|
<PanelStack>
|
|
<PinnedHeader>
|
|
<TitleRow>
|
|
{iconSrc && <ToolIcon src={iconSrc} alt="" />}
|
|
<ToolTitle>{resolveDisplayLabel(name, title)}</ToolTitle>
|
|
{description && (
|
|
<DescriptionToggle
|
|
aria-label={
|
|
descriptionOpen ? "Hide description" : "Show description"
|
|
}
|
|
aria-expanded={descriptionOpen}
|
|
aria-controls={descriptionRegionId}
|
|
onClick={() => setDescriptionOpen((open) => !open)}
|
|
>
|
|
{descriptionOpen ? <RiArrowDownSLine /> : <RiArrowRightSLine />}
|
|
</DescriptionToggle>
|
|
)}
|
|
</TitleRow>
|
|
{hasAnyAnnotation(annotations) && annotations && (
|
|
<Group gap="xs">
|
|
{annotations.readOnlyHint && (
|
|
<AnnotationBadge facet="readOnlyHint" value={true} />
|
|
)}
|
|
{annotations.destructiveHint && (
|
|
<AnnotationBadge facet="destructiveHint" value={true} />
|
|
)}
|
|
{annotations.idempotentHint && (
|
|
<AnnotationBadge facet="idempotentHint" value={true} />
|
|
)}
|
|
{annotations.openWorldHint && (
|
|
<AnnotationBadge facet="openWorldHint" value={true} />
|
|
)}
|
|
</Group>
|
|
)}
|
|
</PinnedHeader>
|
|
|
|
<BodyScroll>
|
|
<BodyStack>
|
|
{description && (
|
|
<Collapse in={descriptionOpen} id={descriptionRegionId}>
|
|
<DescriptionText>{description}</DescriptionText>
|
|
</Collapse>
|
|
)}
|
|
|
|
<Divider />
|
|
|
|
<SchemaForm
|
|
schema={formSchema}
|
|
values={formValues}
|
|
onChange={onFormChange}
|
|
disabled={isExecuting}
|
|
/>
|
|
|
|
{progress && <ProgressDisplay params={progress} />}
|
|
</BodyStack>
|
|
</BodyScroll>
|
|
|
|
{showRunAsTask && (
|
|
<TaskToggleRow>
|
|
<RunAsTaskSwitch
|
|
checked={effectiveRunAsTask}
|
|
disabled={isExecuting || taskSupport === "required"}
|
|
onChange={(event) => onRunAsTaskChange(event.currentTarget.checked)}
|
|
/>
|
|
</TaskToggleRow>
|
|
)}
|
|
|
|
<FooterRow>
|
|
<Button
|
|
size="md"
|
|
onClick={() => onExecute(effectiveRunAsTask)}
|
|
disabled={isExecuting}
|
|
loading={isExecuting}
|
|
>
|
|
Execute Tool
|
|
</Button>
|
|
{isExecuting && <CancelButton onClick={onCancel}>Cancel</CancelButton>}
|
|
</FooterRow>
|
|
</PanelStack>
|
|
);
|
|
}
|