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>
This commit is contained in:
cliffhall 2026-07-15 10:44:24 -04:00
commit 19a6df7ad3
6 changed files with 2545 additions and 2123 deletions

View file

@ -7,7 +7,7 @@ import {
collectSchemaDefaults,
hasMissingRequiredFields,
} from "./jsonUtils";
import type { JsonSchemaType } from "./jsonUtils";
import type { InspectorFormSchema } from "./jsonUtils";
describe("getDataType", () => {
it("returns 'array' for arrays", () => {
@ -172,7 +172,7 @@ describe("updateValueAtPath edge case suppression", () => {
describe("collectSchemaDefaults", () => {
it("collects defaults across field types and omits fields without one", () => {
const schema: JsonSchemaType = {
const schema: InspectorFormSchema = {
type: "object",
properties: {
firstLine: {
@ -197,7 +197,7 @@ describe("collectSchemaDefaults", () => {
});
it("recurses into nested object schemas and skips empty ones", () => {
const schema: JsonSchemaType = {
const schema: InspectorFormSchema = {
type: "object",
properties: {
db: {
@ -224,7 +224,7 @@ describe("collectSchemaDefaults", () => {
});
describe("hasMissingRequiredFields", () => {
const schema: JsonSchemaType = {
const schema: InspectorFormSchema = {
type: "object",
properties: { name: { type: "string" }, age: { type: "integer" } },
required: ["name"],

View file

@ -13,7 +13,7 @@ export type JsonSchemaConst = {
description?: string;
};
export type JsonSchemaType = {
export type InspectorFormSchema = {
type?:
| "string"
| "number"
@ -35,8 +35,8 @@ export type JsonSchemaType = {
description?: string;
required?: string[];
default?: JsonValue;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
properties?: Record<string, InspectorFormSchema>;
items?: InspectorFormSchema;
// Array validation constraints
minItems?: number;
maxItems?: number;
@ -51,13 +51,35 @@ export type JsonSchemaType = {
// Non-standard legacy support: titles for enum values
enumNames?: string[];
const?: JsonValue;
oneOf?: (JsonSchemaType | JsonSchemaConst)[];
anyOf?: (JsonSchemaType | JsonSchemaConst)[];
oneOf?: (InspectorFormSchema | JsonSchemaConst)[];
anyOf?: (InspectorFormSchema | JsonSchemaConst)[];
$ref?: string;
};
export type JsonObject = { [key: string]: JsonValue };
/**
* Narrow an MCP protocol schema (SDK `JsonSchemaType` — e.g. `Tool["inputSchema"]`
* / `outputSchema`, an elicitation `requestedSchema`) to the {@link
* InspectorFormSchema} subset the {@link SchemaForm} renderer understands.
*
* Under SDK v2 the protocol schema type (from `json-schema-typed`, exported as
* `JsonSchemaType` from `@modelcontextprotocol/client`) is structurally distinct
* from Inspector's form schema — same JSON on the wire, incompatible TS types.
* Rather than cast at every call site, callers pass the SDK schema through here.
* Returns `null` when there is no renderable object shape (missing schema, or a
* non-object schema the form can't build fields from); callers handle `null`.
*/
export function toFormSchema(schema: unknown): InspectorFormSchema | null {
if (schema == null || typeof schema !== "object" || Array.isArray(schema)) {
return null;
}
// Structural narrow: the SDK schema's fields are a superset of what the form
// reads (`type`, `properties`, `required`, `items`, …); the values the form
// never dereferences don't affect rendering.
return schema as InspectorFormSchema;
}
export type DataType =
| "string"
| "number"
@ -90,7 +112,7 @@ export function getDataType(value: JsonValue): DataType {
* omits fields that have no default.
*/
export function collectSchemaDefaults(
schema: JsonSchemaType,
schema: InspectorFormSchema,
): Record<string, unknown> {
const properties = schema.properties ?? {};
const result: Record<string, unknown> = {};
@ -113,7 +135,7 @@ export function collectSchemaDefaults(
* action until required fields are supplied.
*/
export function hasMissingRequiredFields(
schema: JsonSchemaType,
schema: InspectorFormSchema,
values: Record<string, unknown>,
): boolean {
const required = schema.required ?? [];

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Tool, JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import type { Tool, JSONRPCMessage } from "@modelcontextprotocol/client";
import {
cacheToolOutputSchemas,
getToolOutputValidator,

View file

@ -1,8 +1,8 @@
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
import type { JsonValue, InspectorFormSchema, JsonObject } from "./jsonUtils";
import Ajv from "ajv";
import type { ValidateFunction } from "ajv";
import type { Tool, JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
import type { Tool, JSONRPCMessage } from "@modelcontextprotocol/client";
import { isJSONRPCRequest } from "@modelcontextprotocol/client";
const ajv = new Ajv();
@ -87,9 +87,9 @@ export function hasOutputSchema(toolName: string): boolean {
* @returns A default value matching the schema type
*/
export function generateDefaultValue(
schema: JsonSchemaType,
schema: InspectorFormSchema,
propertyName?: string,
parentSchema?: JsonSchemaType,
parentSchema?: InspectorFormSchema,
): JsonValue {
if ("default" in schema && schema.default !== undefined) {
return schema.default;
@ -121,7 +121,8 @@ export function generateDefaultValue(
// Include required properties OR optional properties that declare a default
Object.entries(schema.properties).forEach(([key, prop]) => {
const hasExplicitDefault =
"default" in prop && (prop as JsonSchemaType).default !== undefined;
"default" in prop &&
(prop as InspectorFormSchema).default !== undefined;
if (isPropertyRequired(key, schema) || hasExplicitDefault) {
const value = generateDefaultValue(prop, key, schema);
if (value !== undefined) {
@ -150,7 +151,7 @@ export function generateDefaultValue(
*/
export function isPropertyRequired(
propertyName: string,
schema: JsonSchemaType,
schema: InspectorFormSchema,
): boolean {
return schema.required?.includes(propertyName) ?? false;
}
@ -162,9 +163,9 @@ export function isPropertyRequired(
* @returns The resolved schema without $ref
*/
export function resolveRef(
schema: JsonSchemaType,
rootSchema: JsonSchemaType,
): JsonSchemaType {
schema: InspectorFormSchema,
rootSchema: InspectorFormSchema,
): InspectorFormSchema {
if (!("$ref" in schema) || !schema.$ref) {
return schema;
}
@ -191,7 +192,7 @@ export function resolveRef(
}
}
return current as JsonSchemaType;
return current as InspectorFormSchema;
}
// For other types of references, return the original schema
@ -204,13 +205,15 @@ export function resolveRef(
* @param schema The JSON schema to normalize
* @returns A normalized schema or the original schema
*/
export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
export function normalizeUnionType(
schema: InspectorFormSchema,
): InspectorFormSchema {
// Handle anyOf with exactly string and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "string") &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null")
) {
return { ...schema, type: "string", anyOf: undefined, nullable: true };
}
@ -219,8 +222,8 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "boolean") &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null")
) {
return { ...schema, type: "boolean", anyOf: undefined, nullable: true };
}
@ -229,8 +232,8 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "number") &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null")
) {
return { ...schema, type: "number", anyOf: undefined, nullable: true };
}
@ -239,8 +242,8 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "integer") &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null")
) {
return { ...schema, type: "integer", anyOf: undefined, nullable: true };
}
@ -249,8 +252,8 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "array") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "array") &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null")
) {
return { ...schema, type: "array", anyOf: undefined, nullable: true };
}
@ -320,7 +323,7 @@ export function resolveRefsInMessage(message: JSONRPCMessage): JSONRPCMessage {
return message;
}
const requestedSchema = message.params.requestedSchema as JsonSchemaType;
const requestedSchema = message.params.requestedSchema as InspectorFormSchema;
if (!requestedSchema?.properties) {
return message;

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import type { Tool } from "@modelcontextprotocol/client";
import { hasInputFields, resolveDisplayLabel } from "./toolUtils";
describe("resolveDisplayLabel", () => {

View file

@ -1,4 +1,4 @@
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import type { Tool } from "@modelcontextprotocol/client";
/**
* Returns the display label for an MCP entity that follows the BaseMetadata