Merge branch 'v2/main' into v2/fix/1909-salvage-malformed-list-items
This commit is contained in:
commit
23847a61c7
4 changed files with 2458 additions and 260 deletions
|
|
@ -221,6 +221,42 @@ describe("collectSchemaDefaults", () => {
|
|||
it("returns an empty object when the schema has no properties", () => {
|
||||
expect(collectSchemaDefaults({ type: "object" })).toEqual({});
|
||||
});
|
||||
|
||||
// The form normalizes a nullable union before rendering, so this has to as
|
||||
// well: a nested object's `properties` live on the union's surviving branch,
|
||||
// and a default only visible there would be displayed but never submitted.
|
||||
it("collects defaults hoisted out of a nullable object union", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
profile: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { nick: { type: "string", default: "ada" } },
|
||||
},
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(collectSchemaDefaults(schema)).toEqual({
|
||||
profile: { nick: "ada" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a default declared on the union wrapper itself", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
direction: {
|
||||
default: "envio",
|
||||
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(collectSchemaDefaults(schema)).toEqual({ direction: "envio" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasMissingRequiredFields", () => {
|
||||
|
|
@ -247,4 +283,60 @@ describe("hasMissingRequiredFields", () => {
|
|||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// JSON Schema `required` constrains presence, not content — so an explicit
|
||||
// `null` satisfies a required field whose schema admits null. Since #1928 the
|
||||
// user can produce exactly that by clearing a nullable enum, and treating it
|
||||
// as missing would disable submit on a value the schema calls valid.
|
||||
it("accepts an explicit null for a required field that admits null", () => {
|
||||
const nullable: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
direction: {
|
||||
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
required: ["direction"],
|
||||
};
|
||||
expect(hasMissingRequiredFields(nullable, { direction: null })).toBe(false);
|
||||
expect(hasMissingRequiredFields(nullable, {})).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an explicit null for a required type: [T, null] field", () => {
|
||||
const nullable: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: { note: { type: ["string", "null"] } },
|
||||
required: ["note"],
|
||||
};
|
||||
expect(hasMissingRequiredFields(nullable, { note: null })).toBe(false);
|
||||
});
|
||||
|
||||
it("still rejects null for a required field whose schema is not nullable", () => {
|
||||
expect(hasMissingRequiredFields(schema, { name: null })).toBe(true);
|
||||
});
|
||||
|
||||
// The renderer's collapse only handles a two-member union, but null admission
|
||||
// has no such limit — this one renders through the JSON fallback, where the
|
||||
// user can still type `null`, and the schema plainly accepts it.
|
||||
it("accepts an explicit null for a required three-member union", () => {
|
||||
const wide: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
mixed: {
|
||||
anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
required: ["mixed"],
|
||||
};
|
||||
expect(hasMissingRequiredFields(wide, { mixed: null })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects null for a required field the schema does not describe", () => {
|
||||
const undescribed: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: ["ghost"],
|
||||
};
|
||||
expect(hasMissingRequiredFields(undescribed, { ghost: null })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
admitsNull,
|
||||
normalizeNullableUnion,
|
||||
} from "@inspector/core/json/nullableUnion.js";
|
||||
|
||||
export type JsonValue =
|
||||
| string
|
||||
| number
|
||||
|
|
@ -116,7 +121,12 @@ export function collectSchemaDefaults(
|
|||
): Record<string, unknown> {
|
||||
const properties = schema.properties ?? {};
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
|
||||
for (const [fieldName, rawSchema] of Object.entries(properties)) {
|
||||
// Collapse a nullable union first, for the same reason `SchemaForm` does:
|
||||
// a nested object's `properties` live on the union's surviving branch, so
|
||||
// without this the form would *display* a hoisted default that never
|
||||
// reached the seeded values — the field would submit empty (#1928).
|
||||
const fieldSchema = normalizeNullableUnion(rawSchema);
|
||||
if (fieldSchema.default !== undefined) {
|
||||
result[fieldName] = fieldSchema.default;
|
||||
} else if (fieldSchema.type === "object" && fieldSchema.properties) {
|
||||
|
|
@ -133,15 +143,33 @@ export function collectSchemaDefaults(
|
|||
* Whether any of the schema's required top-level fields is missing a value in
|
||||
* `values` (absent, null, or empty string). Used to gate a form's submit
|
||||
* action until required fields are supplied.
|
||||
*
|
||||
* `null` counts as missing only for a field that does not admit it. JSON
|
||||
* Schema's `required` constrains *presence*, not content, so a required field
|
||||
* whose schema is a nullable union is satisfied by an explicit `null` — and
|
||||
* since #1928 the user can produce exactly that by clearing a nullable enum.
|
||||
* Treating it as missing would leave the form's submit permanently disabled on
|
||||
* a value the schema calls valid.
|
||||
*
|
||||
* The test is `admitsNull`, **not** whether the renderer's collapse recognized
|
||||
* the field. Those differ: the collapse only handles a two-member union, so a
|
||||
* three-member `anyOf: [string, number, null]` renders through the JSON
|
||||
* fallback — where a user can still type `null` — while plainly admitting it.
|
||||
* Gating on the collapse would reject a value the schema accepts.
|
||||
*/
|
||||
export function hasMissingRequiredFields(
|
||||
schema: InspectorFormSchema,
|
||||
values: Record<string, unknown>,
|
||||
): boolean {
|
||||
const required = schema.required ?? [];
|
||||
const properties = schema.properties ?? {};
|
||||
return required.some((field) => {
|
||||
const value = values[field];
|
||||
return value === undefined || value === null || value === "";
|
||||
if (value === null) {
|
||||
const fieldSchema = properties[field];
|
||||
return fieldSchema === undefined ? true : !admitsNull(fieldSchema);
|
||||
}
|
||||
return value === undefined || value === "";
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
generateDefaultValue,
|
||||
isPropertyRequired,
|
||||
resolveRef,
|
||||
normalizeUnionType,
|
||||
formatFieldLabel,
|
||||
resolveRefsInMessage,
|
||||
} from "./schemaUtils";
|
||||
|
|
@ -243,112 +242,6 @@ describe("resolveRef", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("normalizeUnionType", () => {
|
||||
it("normalizes anyOf string|null", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "string",
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes anyOf boolean|null", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ type: "boolean" }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "boolean",
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes anyOf number|null", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ type: "number" }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "number",
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes anyOf integer|null", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ type: "integer" }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "integer",
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes anyOf array|null", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ type: "array" }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "array",
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes type array string|null", () => {
|
||||
expect(normalizeUnionType({ type: ["string", "null"] })).toEqual({
|
||||
type: "string",
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes type array boolean|null", () => {
|
||||
expect(normalizeUnionType({ type: ["boolean", "null"] })).toEqual({
|
||||
type: "boolean",
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes type array number|null", () => {
|
||||
expect(normalizeUnionType({ type: ["number", "null"] })).toEqual({
|
||||
type: "number",
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes type array integer|null", () => {
|
||||
expect(normalizeUnionType({ type: ["integer", "null"] })).toEqual({
|
||||
type: "integer",
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns schema unchanged when no union pattern matches", () => {
|
||||
const schema = { type: "string" as const };
|
||||
expect(normalizeUnionType(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
it("ignores anyOf with more than two members", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{ type: "string" as const },
|
||||
{ type: "null" as const },
|
||||
{ type: "number" as const },
|
||||
],
|
||||
};
|
||||
expect(normalizeUnionType(schema)).toBe(schema);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatFieldLabel", () => {
|
||||
it("inserts spaces before capitals and capitalizes first letter", () => {
|
||||
expect(formatFieldLabel("firstName")).toBe("First Name");
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import Ajv from "ajv";
|
|||
import type { ValidateFunction } from "ajv";
|
||||
import type { Tool, JSONRPCMessage } from "@modelcontextprotocol/client";
|
||||
import { isJSONRPCRequest } from "@modelcontextprotocol/client";
|
||||
import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js";
|
||||
|
||||
const ajv = new Ajv();
|
||||
|
||||
|
|
@ -200,107 +201,6 @@ export function resolveRef(
|
|||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes union types (like string|null from FastMCP) to simple types for form rendering
|
||||
* @param schema The JSON schema to normalize
|
||||
* @returns A normalized schema or the original schema
|
||||
*/
|
||||
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 InspectorFormSchema).type === "string") &&
|
||||
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null")
|
||||
) {
|
||||
return { ...schema, type: "string", anyOf: undefined, nullable: true };
|
||||
}
|
||||
|
||||
// Handle anyOf with exactly boolean and null (FastMCP pattern)
|
||||
if (
|
||||
schema.anyOf &&
|
||||
schema.anyOf.length === 2 &&
|
||||
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 };
|
||||
}
|
||||
|
||||
// Handle anyOf with exactly number and null (FastMCP pattern)
|
||||
if (
|
||||
schema.anyOf &&
|
||||
schema.anyOf.length === 2 &&
|
||||
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 };
|
||||
}
|
||||
|
||||
// Handle anyOf with exactly integer and null (FastMCP pattern)
|
||||
if (
|
||||
schema.anyOf &&
|
||||
schema.anyOf.length === 2 &&
|
||||
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 };
|
||||
}
|
||||
|
||||
// Handle anyOf with exactly array and null (FastMCP pattern)
|
||||
if (
|
||||
schema.anyOf &&
|
||||
schema.anyOf.length === 2 &&
|
||||
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 };
|
||||
}
|
||||
|
||||
// Handle array type with exactly string and null
|
||||
if (
|
||||
Array.isArray(schema.type) &&
|
||||
schema.type.length === 2 &&
|
||||
schema.type.includes("string") &&
|
||||
schema.type.includes("null")
|
||||
) {
|
||||
return { ...schema, type: "string", nullable: true };
|
||||
}
|
||||
|
||||
// Handle array type with exactly boolean and null
|
||||
if (
|
||||
Array.isArray(schema.type) &&
|
||||
schema.type.length === 2 &&
|
||||
schema.type.includes("boolean") &&
|
||||
schema.type.includes("null")
|
||||
) {
|
||||
return { ...schema, type: "boolean", nullable: true };
|
||||
}
|
||||
|
||||
// Handle array type with exactly number and null
|
||||
if (
|
||||
Array.isArray(schema.type) &&
|
||||
schema.type.length === 2 &&
|
||||
schema.type.includes("number") &&
|
||||
schema.type.includes("null")
|
||||
) {
|
||||
return { ...schema, type: "number", nullable: true };
|
||||
}
|
||||
|
||||
// Handle array type with exactly integer and null
|
||||
if (
|
||||
Array.isArray(schema.type) &&
|
||||
schema.type.length === 2 &&
|
||||
schema.type.includes("integer") &&
|
||||
schema.type.includes("null")
|
||||
) {
|
||||
return { ...schema, type: "integer", nullable: true };
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a field key into a human-readable label
|
||||
* @param key The field key to format
|
||||
|
|
@ -339,7 +239,7 @@ export function resolveRefsInMessage(message: JSONRPCMessage): JSONRPCMessage {
|
|||
Object.entries(requestedSchema.properties).map(
|
||||
([key, propSchema]) => {
|
||||
const resolved = resolveRef(propSchema, requestedSchema);
|
||||
const normalized = normalizeUnionType(resolved);
|
||||
const normalized = normalizeNullableUnion(resolved);
|
||||
return [key, normalized];
|
||||
},
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue