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>
276 lines
7.9 KiB
TypeScript
276 lines
7.9 KiB
TypeScript
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>
|
||
);
|
||
}
|