inspector/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.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

276 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Badge,
Button,
Code,
Flex,
ScrollArea,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import type {
ClientCapabilities,
InitializeResult,
} from "@modelcontextprotocol/client";
import type { ServerType } from "@inspector/core/mcp/types.js";
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
import {
CapabilityItem,
type CapabilityKey,
} from "../../elements/CapabilityItem/CapabilityItem";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
export interface OAuthDetails {
protocol: "standard" | "ema";
authorized: boolean;
clientId?: string;
clientRegistrationKind?: OAuthClientRegistrationKind;
authUrl?: string;
scopes?: string[];
accessToken?: string;
/** EMA only — install-level IdP session for legs 1–2. */
idpSession?: "none" | "logged_in" | "expired";
}
export interface ConnectionInfoContentProps {
initializeResult: InitializeResult;
clientCapabilities: ClientCapabilities;
transport: ServerType;
oauth?: OAuthDetails;
onClearOAuth?: () => void;
}
const ValueText = Text.withProps({
size: "sm",
fw: 600,
});
const SectionHeading = Title.withProps({
// `order: 3` (not 5) keeps the heading level one below the modal's `h2`
// `Modal.Title`, so the outline doesn't skip a level (axe `heading-order`);
// `size: "h5"` preserves the original small visual size.
order: 3,
size: "h5",
variant: "section",
});
// Long OAuth values (client id, auth URL). The `wrapping` variant wraps the
// value onto multiple lines instead of leaving it in a horizontally-scrolling
// `Code` block — that keeps the whole value visible and removes a scroll region
// that would otherwise need its own keyboard access (axe
// `scrollable-region-focusable`).
const ValueCode = Code.withProps({ variant: "wrapping" });
function formatScopes(scopes: string[]): string {
return scopes.join(", ");
}
function formatProtocol(protocol: OAuthDetails["protocol"]): string {
return protocol === "ema" ? "Enterprise-managed" : "Standard";
}
function formatIdpSession(
session: NonNullable<OAuthDetails["idpSession"]>,
): string {
switch (session) {
case "logged_in":
return "Signed in";
case "expired":
return "Session expired";
default:
return "Not signed in";
}
}
function formatClientRegistrationKind(
kind: OAuthClientRegistrationKind,
): string {
switch (kind) {
case "static":
return "Static (preregistered)";
case "dcr":
return "Dynamic (DCR)";
case "cimd":
return "Client ID Metadata (CIMD)";
}
}
const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [
"tools",
"resources",
"prompts",
"logging",
"completions",
"tasks",
"experimental",
];
const CLIENT_CAPABILITY_KEYS: CapabilityKey[] = [
"roots",
"sampling",
"elicitation",
"experimental",
];
export const CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL =
"Clear OAuth state and disconnect";
function getCapabilityEntries(
capabilities: Record<string, unknown>,
knownKeys: CapabilityKey[],
): { capability: CapabilityKey; supported: boolean }[] {
return knownKeys.map((key) => ({
capability: key,
supported: key in capabilities && capabilities[key] != null,
}));
}
export function ConnectionInfoContent({
initializeResult,
clientCapabilities,
transport,
oauth,
onClearOAuth,
}: ConnectionInfoContentProps) {
const { serverInfo, protocolVersion, capabilities, instructions } =
initializeResult;
const serverCaps = getCapabilityEntries(capabilities, SERVER_CAPABILITY_KEYS);
const clientCaps = getCapabilityEntries(
clientCapabilities,
CLIENT_CAPABILITY_KEYS,
);
return (
<Stack gap="md">
<Stack gap="xs">
<SectionHeading>Server Implementation</SectionHeading>
<SimpleGrid cols={2}>
<Text size="sm">Name</Text>
<ValueText>{serverInfo.name}</ValueText>
<Text size="sm">Version</Text>
<ValueText>{serverInfo.version ?? "—"}</ValueText>
<Text size="sm">Protocol</Text>
<ValueText>{protocolVersion}</ValueText>
<Text size="sm">Transport</Text>
<Badge variant="outline">{transport}</Badge>
</SimpleGrid>
</Stack>
<SimpleGrid cols={2}>
<Stack gap="xs">
<SectionHeading>Server Capabilities</SectionHeading>
{serverCaps.map((cap) => (
<CapabilityItem
key={cap.capability}
capability={cap.capability}
supported={cap.supported}
/>
))}
</Stack>
<Stack gap="xs">
<SectionHeading>Client Capabilities</SectionHeading>
{clientCaps.map((cap) => (
<CapabilityItem
key={cap.capability}
capability={cap.capability}
supported={cap.supported}
/>
))}
</Stack>
</SimpleGrid>
{instructions && (
<Stack gap="xs">
<SectionHeading>Server Instructions</SectionHeading>
{/* Cap the instructions block so a long server prompt scrolls
inside the section instead of pushing the OAuth section and
modal chrome off-screen. */}
<ScrollArea.Autosize mah={280}>
<ContentViewer
block={{ type: "text", text: instructions }}
copyable
/>
</ScrollArea.Autosize>
</Stack>
)}
{oauth && (
<Stack gap="xs">
<SectionHeading>OAuth Details</SectionHeading>
<Stack gap="xs">
<SimpleGrid cols={2}>
<Text size="sm">Protocol</Text>
<ValueText>{formatProtocol(oauth.protocol)}</ValueText>
<Text size="sm">Status</Text>
<Badge
variant="outline"
color={oauth.authorized ? "green" : "gray"}
>
{oauth.authorized ? "Authorized" : "Not authorized"}
</Badge>
</SimpleGrid>
{oauth.clientId && (
<SimpleGrid cols={2}>
<Text size="sm">Client ID</Text>
<ValueCode>{oauth.clientId}</ValueCode>
</SimpleGrid>
)}
{oauth.clientRegistrationKind && (
<SimpleGrid cols={2}>
<Text size="sm">Client registration</Text>
<ValueText>
{formatClientRegistrationKind(oauth.clientRegistrationKind)}
</ValueText>
</SimpleGrid>
)}
{oauth.protocol === "ema" && oauth.idpSession && (
<SimpleGrid cols={2}>
<Text size="sm">IdP session</Text>
<ValueText>{formatIdpSession(oauth.idpSession)}</ValueText>
</SimpleGrid>
)}
{oauth.authUrl && (
<SimpleGrid cols={2}>
<Text size="sm">Auth URL</Text>
<ValueCode>{oauth.authUrl}</ValueCode>
</SimpleGrid>
)}
{oauth.scopes && oauth.scopes.length > 0 && (
<SimpleGrid cols={2}>
<Text size="sm">Scopes</Text>
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
</SimpleGrid>
)}
{oauth.accessToken ? (
<OAuthAccessTokenField
accessToken={oauth.accessToken}
onClear={onClearOAuth}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>
) : (
onClearOAuth && (
<Flex justify="flex-end">
<Button
variant="subtle"
color="red"
size="compact-xs"
onClick={onClearOAuth}
>
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
</Button>
</Flex>
)
)}
</Stack>
</Stack>
)}
</Stack>
);
}