inspector/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx
cliffhall 19a6df7ad3 SDK v2 migration (behavior-neutral, versionNegotiation: 'legacy') (#1624)
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>
2026-07-15 10:44:24 -04:00

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>
);
}