Merge pull request #673 from matsjfunke/fix/fastmcp-union-types

fix: support FastMCP union types in tool parameter forms
This commit is contained in:
Ola Hungerford 2025-08-10 20:04:46 -07:00 • committed by GitHub
commit 113b612b99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 326 additions and 7 deletions

View file

@ -15,12 +15,22 @@ interface DynamicJsonFormProps {
maxDepth?: number;
}
const isTypeSupported = (
type: JsonSchemaType["type"],
supportedTypes: string[],
): boolean => {
if (Array.isArray(type)) {
return type.every((t) => supportedTypes.includes(t));
}
return typeof type === "string" && supportedTypes.includes(type);
};
const isSimpleObject = (schema: JsonSchemaType): boolean => {
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
if (schema.type && supportedTypes.includes(schema.type)) return true;
if (schema.type && isTypeSupported(schema.type, supportedTypes)) return true;
if (schema.type === "object") {
return Object.values(schema.properties ?? {}).every(
(prop) => prop.type && supportedTypes.includes(prop.type),
(prop) => prop.type && isTypeSupported(prop.type, supportedTypes),
);
}
if (schema.type === "array") {
@ -181,7 +191,13 @@ const DynamicJsonForm = ({
const isRequired =
parentSchema?.required?.includes(propertyName || "") ?? false;
switch (propSchema.type) {
let fieldType = propSchema.type;
if (Array.isArray(fieldType)) {
// Of the possible types, find the first non-null type to determine the control to render
fieldType = fieldType.find((t) => t !== "null") ?? fieldType[0];
}
switch (fieldType) {
case "string": {
if (
propSchema.oneOf &&
@ -337,6 +353,8 @@ const DynamicJsonForm = ({
required={isRequired}
/>
);
case "null":
return null;
case "object":
if (!propSchema.properties) {
return (

View file

@ -7,7 +7,11 @@ import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
import {
generateDefaultValue,
isPropertyRequired,
normalizeUnionType,
} from "@/utils/schemaUtils";
import {
CompatibilityCallToolResult,
ListToolsResult,
@ -104,7 +108,7 @@ const ToolsTab = ({
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = value as JsonSchemaType;
const prop = normalizeUnionType(value as JsonSchemaType);
const inputSchema =
selectedTool.inputSchema as JsonSchemaType;
const required = isPropertyRequired(key, inputSchema);
@ -148,7 +152,10 @@ const ToolsTab = ({
onChange={(e) =>
setParams({
...params,
[key]: e.target.value,
[key]:
e.target.value === ""
? undefined
: e.target.value,
})
}
className="mt-1"

View file

@ -35,6 +35,18 @@ describe("DynamicJsonForm String Fields", () => {
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "text");
});
it("should handle a union type of string and null", () => {
const schema: JsonSchemaType = {
type: ["string", "null"],
description: "Test string or null field",
};
render(
<DynamicJsonForm schema={schema} value={null} onChange={jest.fn()} />,
);
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "text");
});
});
describe("Format Support", () => {

View file

@ -1,6 +1,7 @@
import {
generateDefaultValue,
formatFieldLabel,
normalizeUnionType,
cacheToolOutputSchemas,
getToolOutputValidator,
validateToolOutput,
@ -142,6 +143,189 @@ describe("formatFieldLabel", () => {
});
});
describe("normalizeUnionType", () => {
test("normalizes anyOf with string and null to string type", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Optional string parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("string");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional string parameter");
});
test("normalizes anyOf with boolean and null to boolean type", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "boolean" }, { type: "null" }],
description: "Optional boolean parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("boolean");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional boolean parameter");
});
test("normalizes array type with string and null to string type", () => {
const schema: JsonSchemaType = {
type: ["string", "null"],
description: "Optional string parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("string");
expect(normalized.description).toBe("Optional string parameter");
});
test("normalizes array type with boolean and null to boolean type", () => {
const schema: JsonSchemaType = {
type: ["boolean", "null"],
description: "Optional boolean parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("boolean");
expect(normalized.description).toBe("Optional boolean parameter");
});
test("normalizes anyOf with number and null to number type", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "number" }, { type: "null" }],
description: "Optional number parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("number");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional number parameter");
});
test("normalizes anyOf with integer and null to integer type", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "integer" }, { type: "null" }],
description: "Optional integer parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("integer");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional integer parameter");
});
test("normalizes array type with number and null to number type", () => {
const schema: JsonSchemaType = {
type: ["number", "null"],
description: "Optional number parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("number");
expect(normalized.description).toBe("Optional number parameter");
});
test("normalizes array type with integer and null to integer type", () => {
const schema: JsonSchemaType = {
type: ["integer", "null"],
description: "Optional integer parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("integer");
expect(normalized.description).toBe("Optional integer parameter");
});
test("handles anyOf with reversed order (null first)", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "null" }, { type: "string" }],
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("string");
expect(normalized.anyOf).toBeUndefined();
});
test("leaves non-union schemas unchanged", () => {
const schema: JsonSchemaType = {
type: "string",
description: "Regular string parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized).toEqual(schema);
});
test("leaves anyOf with non-matching types unchanged", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "string" }, { type: "number" }],
};
const normalized = normalizeUnionType(schema);
expect(normalized).toEqual(schema);
});
test("leaves anyOf with more than two types unchanged", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }],
};
const normalized = normalizeUnionType(schema);
expect(normalized).toEqual(schema);
});
test("leaves array type with non-matching types unchanged", () => {
const schema: JsonSchemaType = {
type: ["string", "number"],
};
const normalized = normalizeUnionType(schema);
expect(normalized).toEqual(schema);
});
test("handles schemas without type or anyOf", () => {
const schema: JsonSchemaType = {
description: "Schema without type",
};
const normalized = normalizeUnionType(schema);
expect(normalized).toEqual(schema);
});
test("preserves other properties when normalizing", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Optional string",
minLength: 1,
maxLength: 100,
pattern: "^[a-z]+$",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("string");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional string");
expect(normalized.minLength).toBe(1);
expect(normalized.maxLength).toBe(100);
expect(normalized.pattern).toBe("^[a-z]+$");
});
});
describe("Output Schema Validation", () => {
const mockTools: Tool[] = [
{

View file

@ -21,7 +21,16 @@ export type JsonSchemaType = {
| "boolean"
| "array"
| "object"
| "null";
| "null"
| (
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null"
)[];
title?: string;
description?: string;
required?: string[];

View file

@ -145,6 +145,95 @@ export function isPropertyRequired(
return schema.required?.includes(propertyName) ?? false;
}
/**
* 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: JsonSchemaType): JsonSchemaType {
// 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")
) {
return { ...schema, type: "string", anyOf: undefined };
}
// Handle anyOf with exactly boolean and null (FastMCP pattern)
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")
) {
return { ...schema, type: "boolean", anyOf: undefined };
}
// Handle anyOf with exactly number and null (FastMCP pattern)
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")
) {
return { ...schema, type: "number", anyOf: undefined };
}
// Handle anyOf with exactly integer and null (FastMCP pattern)
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")
) {
return { ...schema, type: "integer", anyOf: undefined };
}
// 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" };
}
// 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" };
}
// 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" };
}
// 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" };
}
return schema;
}
/**
* Formats a field key into a human-readable label
* @param key The field key to format