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:
parent
8d33e76f97
commit
19a6df7ad3
6 changed files with 2545 additions and 2123 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue