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
2 changed files with 2545 additions and 2123 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { JsonSchemaType } from "../../../utils/jsonUtils";
|
||||
import type { InspectorFormSchema } from "../../../utils/jsonUtils";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { SchemaForm } from "./SchemaForm";
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ describe("SchemaForm", () => {
|
|||
it("renders a string TextInput and propagates onChange", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", title: "Name" },
|
||||
|
|
@ -26,7 +26,7 @@ describe("SchemaForm", () => {
|
|||
it("renders a Number/Integer field and propagates a numeric value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", title: "Count", minimum: 0, maximum: 100 },
|
||||
|
|
@ -47,7 +47,7 @@ describe("SchemaForm", () => {
|
|||
it("renders a checkbox for boolean fields and toggles on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean", title: "Enabled" },
|
||||
|
|
@ -63,7 +63,7 @@ describe("SchemaForm", () => {
|
|||
|
||||
it("renders an enum Select with the supplied options", () => {
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
format: {
|
||||
|
|
@ -87,7 +87,7 @@ describe("SchemaForm", () => {
|
|||
it("invokes onChange when an enum Select option is chosen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
format: {
|
||||
|
|
@ -112,7 +112,7 @@ describe("SchemaForm", () => {
|
|||
it("clears a string field via its Clear button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", title: "Name" },
|
||||
|
|
@ -133,7 +133,7 @@ describe("SchemaForm", () => {
|
|||
it("passes undefined to onChange when a number field is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", title: "Count" },
|
||||
|
|
@ -153,7 +153,7 @@ describe("SchemaForm", () => {
|
|||
|
||||
it("falls back to empty/const labels for oneOf items missing const and title", () => {
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
choice: {
|
||||
|
|
@ -173,7 +173,7 @@ describe("SchemaForm", () => {
|
|||
|
||||
it("falls back to empty/const labels for anyOf items missing const and title", () => {
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: {
|
||||
|
|
@ -195,7 +195,7 @@ describe("SchemaForm", () => {
|
|||
|
||||
it("renders an oneOf Select using titles for labels", () => {
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
size: {
|
||||
|
|
@ -218,7 +218,7 @@ describe("SchemaForm", () => {
|
|||
it("invokes onChange when a oneOf Select option is chosen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
size: {
|
||||
|
|
@ -246,7 +246,7 @@ describe("SchemaForm", () => {
|
|||
it("renders a MultiSelect for array with anyOf items and invokes onChange when an option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: {
|
||||
|
|
@ -277,7 +277,7 @@ describe("SchemaForm", () => {
|
|||
it("renders a MultiSelect for an array of enum items and invokes onChange when an option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
instruments: {
|
||||
|
|
@ -306,7 +306,7 @@ describe("SchemaForm", () => {
|
|||
it("uses enumNames for enum-array option labels and the raw value on change", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
sizes: {
|
||||
|
|
@ -336,7 +336,7 @@ describe("SchemaForm", () => {
|
|||
|
||||
it("renders nested object fields recursively", () => {
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
address: {
|
||||
|
|
@ -360,7 +360,7 @@ describe("SchemaForm", () => {
|
|||
it("propagates nested object changes back to top-level onChange", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
address: {
|
||||
|
|
@ -383,7 +383,7 @@ describe("SchemaForm", () => {
|
|||
|
||||
it("falls back to a JsonInput for complex/unsupported schemas", () => {
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
config: {
|
||||
|
|
@ -406,7 +406,7 @@ describe("SchemaForm", () => {
|
|||
it("invokes onChange via JsonInput when valid JSON is pasted", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
config: { type: "array", title: "Config" },
|
||||
|
|
@ -426,7 +426,7 @@ describe("SchemaForm", () => {
|
|||
it("falls back to passing raw string to onChange when JSON is invalid in JsonInput", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
config: { type: "array", title: "Config" },
|
||||
|
|
@ -443,7 +443,7 @@ describe("SchemaForm", () => {
|
|||
});
|
||||
|
||||
it("uses default values when value is undefined", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", title: "Name", default: "Alice" },
|
||||
|
|
@ -456,7 +456,7 @@ describe("SchemaForm", () => {
|
|||
});
|
||||
|
||||
it("respects the disabled prop on inputs", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", title: "Name" },
|
||||
|
|
@ -476,7 +476,7 @@ describe("SchemaForm", () => {
|
|||
});
|
||||
|
||||
it("uses field name when title is missing", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
rawField: { type: "string" },
|
||||
|
|
@ -489,7 +489,7 @@ describe("SchemaForm", () => {
|
|||
});
|
||||
|
||||
it("renders nothing inside the form when properties are missing", () => {
|
||||
const schema: JsonSchemaType = { type: "object" };
|
||||
const schema: InspectorFormSchema = { type: "object" };
|
||||
const { container } = renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import type { JsonSchemaType } from "../../../utils/jsonUtils";
|
||||
import type { InspectorFormSchema } from "../../../utils/jsonUtils";
|
||||
|
||||
const FieldLabel = Text.withProps({
|
||||
fw: 500,
|
||||
|
|
@ -26,20 +26,23 @@ function serializeJson(value: unknown): string {
|
|||
}
|
||||
|
||||
export interface SchemaFormProps {
|
||||
schema: JsonSchemaType;
|
||||
schema: InspectorFormSchema;
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function getDefaultValue(fieldSchema: JsonSchemaType): unknown {
|
||||
function getDefaultValue(fieldSchema: InspectorFormSchema): unknown {
|
||||
if (fieldSchema.default !== undefined) {
|
||||
return fieldSchema.default;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveValue(value: unknown, fieldSchema: JsonSchemaType): unknown {
|
||||
function resolveValue(
|
||||
value: unknown,
|
||||
fieldSchema: InspectorFormSchema,
|
||||
): unknown {
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
|
|
@ -59,7 +62,7 @@ export function SchemaForm({
|
|||
onChange({ ...values, [fieldName]: fieldValue });
|
||||
}
|
||||
|
||||
function renderField(fieldName: string, fieldSchema: JsonSchemaType) {
|
||||
function renderField(fieldName: string, fieldSchema: InspectorFormSchema) {
|
||||
const isRequired = requiredFields.includes(fieldName);
|
||||
const label = fieldSchema.title ?? fieldName;
|
||||
const description = fieldSchema.description;
|
||||
|
|
|
|||
Loading…
Reference in a new issue