Merge pull request #850 from gregsaab/greg/add_null_support_to_tools

Add support for specifying `null` values for tool fields
This commit is contained in:
Cliff Hall 2025-10-11 12:01:02 -04:00 • committed by GitHub
commit ccb68b9253
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 250 additions and 167 deletions

View file

@ -160,6 +160,7 @@ const ToolsTab = ({
const required = isPropertyRequired(key, inputSchema); const required = isPropertyRequired(key, inputSchema);
return ( return (
<div key={key}> <div key={key}>
<div className="flex justify-between">
<Label <Label
htmlFor={key} htmlFor={key}
className="block text-sm font-medium text-gray-700 dark:text-gray-300" className="block text-sm font-medium text-gray-700 dark:text-gray-300"
@ -169,6 +170,33 @@ const ToolsTab = ({
<span className="text-red-500 ml-1">*</span> <span className="text-red-500 ml-1">*</span>
)} )}
</Label> </Label>
{prop.nullable ? (
<div className="flex items-center space-x-2">
<Checkbox
id={key}
name={key}
checked={params[key] === null}
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: checked ? null : prop.default,
})
}
/>
<label
htmlFor={key}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
null
</label>
</div>
) : null}
</div>
<div
role="toolinputwrapper"
className={`${prop.nullable && params[key] === null ? "pointer-events-none opacity-50" : ""}`}
>
{prop.type === "boolean" ? ( {prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2"> <div className="flex items-center space-x-2 mt-2">
<Checkbox <Checkbox
@ -253,7 +281,8 @@ const ToolsTab = ({
}} }}
className="mt-1" className="mt-1"
/> />
) : prop.type === "object" || prop.type === "array" ? ( ) : prop.type === "object" ||
prop.type === "array" ? (
<div className="mt-1"> <div className="mt-1">
<DynamicJsonForm <DynamicJsonForm
ref={(ref) => (formRefs.current[key] = ref)} ref={(ref) => (formRefs.current[key] = ref)}
@ -339,6 +368,7 @@ const ToolsTab = ({
</div> </div>
)} )}
</div> </div>
</div>
); );
}, },
)} )}

View file

@ -44,6 +44,16 @@ describe("ToolsTab", () => {
}, },
}, },
}, },
{
name: "tool4",
description: "Tool with nullable field",
inputSchema: {
type: "object" as const,
properties: {
num: { type: ["number", "null"] as const },
},
},
},
]; ];
const defaultProps = { const defaultProps = {
@ -135,6 +145,38 @@ describe("ToolsTab", () => {
}); });
}); });
it("should allow specifying null value", async () => {
const mockCallTool = jest.fn();
const toolWithNullableField = mockTools[3];
renderToolsTab({
tools: [toolWithNullableField],
selectedTool: toolWithNullableField,
callTool: mockCallTool,
});
const nullToggleButton = screen.getByRole("checkbox", { name: /null/i });
expect(nullToggleButton).toBeInTheDocument();
await act(async () => {
fireEvent.click(nullToggleButton);
});
expect(screen.getByRole("toolinputwrapper").classList).toContain(
"pointer-events-none",
);
const runButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(runButton);
});
// Tool should have been called with null value
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableField.name, {
num: null,
});
});
it("should disable button and change text while tool is running", async () => { it("should disable button and change text while tool is running", async () => {
// Create a promise that we can resolve later // Create a promise that we can resolve later
let resolvePromise: ((value: unknown) => void) | undefined; let resolvePromise: ((value: unknown) => void) | undefined;

View file

@ -188,6 +188,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("boolean"); expect(normalized.type).toBe("boolean");
expect(normalized.anyOf).toBeUndefined(); expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional boolean parameter"); expect(normalized.description).toBe("Optional boolean parameter");
expect(normalized.nullable).toBeTruthy();
}); });
test("normalizes array type with string and null to string type", () => { test("normalizes array type with string and null to string type", () => {
@ -200,6 +201,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("string"); expect(normalized.type).toBe("string");
expect(normalized.description).toBe("Optional string parameter"); expect(normalized.description).toBe("Optional string parameter");
expect(normalized.nullable).toBeTruthy();
}); });
test("normalizes array type with boolean and null to boolean type", () => { test("normalizes array type with boolean and null to boolean type", () => {
@ -212,6 +214,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("boolean"); expect(normalized.type).toBe("boolean");
expect(normalized.description).toBe("Optional boolean parameter"); expect(normalized.description).toBe("Optional boolean parameter");
expect(normalized.nullable).toBeTruthy();
}); });
test("normalizes anyOf with number and null to number type", () => { test("normalizes anyOf with number and null to number type", () => {
@ -225,6 +228,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("number"); expect(normalized.type).toBe("number");
expect(normalized.anyOf).toBeUndefined(); expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional number parameter"); expect(normalized.description).toBe("Optional number parameter");
expect(normalized.nullable).toBeTruthy();
}); });
test("normalizes anyOf with integer and null to integer type", () => { test("normalizes anyOf with integer and null to integer type", () => {
@ -238,6 +242,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("integer"); expect(normalized.type).toBe("integer");
expect(normalized.anyOf).toBeUndefined(); expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional integer parameter"); expect(normalized.description).toBe("Optional integer parameter");
expect(normalized.nullable).toBeTruthy();
}); });
test("normalizes array type with number and null to number type", () => { test("normalizes array type with number and null to number type", () => {
@ -250,6 +255,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("number"); expect(normalized.type).toBe("number");
expect(normalized.description).toBe("Optional number parameter"); expect(normalized.description).toBe("Optional number parameter");
expect(normalized.nullable).toBeTruthy();
}); });
test("normalizes array type with integer and null to integer type", () => { test("normalizes array type with integer and null to integer type", () => {
@ -262,6 +268,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("integer"); expect(normalized.type).toBe("integer");
expect(normalized.description).toBe("Optional integer parameter"); expect(normalized.description).toBe("Optional integer parameter");
expect(normalized.nullable).toBeTruthy();
}); });
test("handles anyOf with reversed order (null first)", () => { test("handles anyOf with reversed order (null first)", () => {
@ -273,6 +280,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("string"); expect(normalized.type).toBe("string");
expect(normalized.anyOf).toBeUndefined(); expect(normalized.anyOf).toBeUndefined();
expect(normalized.nullable).toBeTruthy();
}); });
test("leaves non-union schemas unchanged", () => { test("leaves non-union schemas unchanged", () => {
@ -294,6 +302,7 @@ describe("normalizeUnionType", () => {
const normalized = normalizeUnionType(schema); const normalized = normalizeUnionType(schema);
expect(normalized).toEqual(schema); expect(normalized).toEqual(schema);
expect(normalized.nullable).toBeFalsy();
}); });
test("leaves anyOf with more than two types unchanged", () => { test("leaves anyOf with more than two types unchanged", () => {
@ -343,6 +352,7 @@ describe("normalizeUnionType", () => {
expect(normalized.minLength).toBe(1); expect(normalized.minLength).toBe(1);
expect(normalized.maxLength).toBe(100); expect(normalized.maxLength).toBe(100);
expect(normalized.pattern).toBe("^[a-z]+$"); expect(normalized.pattern).toBe("^[a-z]+$");
expect(normalized.nullable).toBeTruthy();
}); });
}); });

View file

@ -41,6 +41,7 @@ export type JsonSchemaType = {
maximum?: number; maximum?: number;
minLength?: number; minLength?: number;
maxLength?: number; maxLength?: number;
nullable?: boolean;
pattern?: string; pattern?: string;
format?: string; format?: string;
enum?: string[]; enum?: string[];

View file

@ -158,7 +158,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") && schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) { ) {
return { ...schema, type: "string", anyOf: undefined }; return { ...schema, type: "string", anyOf: undefined, nullable: true };
} }
// Handle anyOf with exactly boolean and null (FastMCP pattern) // Handle anyOf with exactly boolean and null (FastMCP pattern)
@ -168,7 +168,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") && schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) { ) {
return { ...schema, type: "boolean", anyOf: undefined }; return { ...schema, type: "boolean", anyOf: undefined, nullable: true };
} }
// Handle anyOf with exactly number and null (FastMCP pattern) // Handle anyOf with exactly number and null (FastMCP pattern)
@ -178,7 +178,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") && schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) { ) {
return { ...schema, type: "number", anyOf: undefined }; return { ...schema, type: "number", anyOf: undefined, nullable: true };
} }
// Handle anyOf with exactly integer and null (FastMCP pattern) // Handle anyOf with exactly integer and null (FastMCP pattern)
@ -188,7 +188,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") && schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) { ) {
return { ...schema, type: "integer", anyOf: undefined }; return { ...schema, type: "integer", anyOf: undefined, nullable: true };
} }
// Handle array type with exactly string and null // Handle array type with exactly string and null
@ -198,7 +198,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
schema.type.includes("string") && schema.type.includes("string") &&
schema.type.includes("null") schema.type.includes("null")
) { ) {
return { ...schema, type: "string" }; return { ...schema, type: "string", nullable: true };
} }
// Handle array type with exactly boolean and null // Handle array type with exactly boolean and null
@ -208,7 +208,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
schema.type.includes("boolean") && schema.type.includes("boolean") &&
schema.type.includes("null") schema.type.includes("null")
) { ) {
return { ...schema, type: "boolean" }; return { ...schema, type: "boolean", nullable: true };
} }
// Handle array type with exactly number and null // Handle array type with exactly number and null
@ -218,7 +218,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
schema.type.includes("number") && schema.type.includes("number") &&
schema.type.includes("null") schema.type.includes("null")
) { ) {
return { ...schema, type: "number" }; return { ...schema, type: "number", nullable: true };
} }
// Handle array type with exactly integer and null // Handle array type with exactly integer and null
@ -228,7 +228,7 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
schema.type.includes("integer") && schema.type.includes("integer") &&
schema.type.includes("null") schema.type.includes("null")
) { ) {
return { ...schema, type: "integer" }; return { ...schema, type: "integer", nullable: true };
} }
return schema; return schema;