Skip to content

Commit 113b612

Browse files
authored
Merge pull request #673 from matsjfunke/fix/fastmcp-union-types
fix: support FastMCP union types in tool parameter forms
2 parents 7a8da07 + 73bf133 commit 113b612

File tree

6 files changed

+326
-7
lines changed

6 files changed

+326
-7
lines changed

client/src/components/DynamicJsonForm.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,22 @@ interface DynamicJsonFormProps {
1515
maxDepth?: number;
1616
}
1717

18+
const isTypeSupported = (
19+
type: JsonSchemaType["type"],
20+
supportedTypes: string[],
21+
): boolean => {
22+
if (Array.isArray(type)) {
23+
return type.every((t) => supportedTypes.includes(t));
24+
}
25+
return typeof type === "string" && supportedTypes.includes(type);
26+
};
27+
1828
const isSimpleObject = (schema: JsonSchemaType): boolean => {
1929
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
20-
if (schema.type && supportedTypes.includes(schema.type)) return true;
30+
if (schema.type && isTypeSupported(schema.type, supportedTypes)) return true;
2131
if (schema.type === "object") {
2232
return Object.values(schema.properties ?? {}).every(
23-
(prop) => prop.type && supportedTypes.includes(prop.type),
33+
(prop) => prop.type && isTypeSupported(prop.type, supportedTypes),
2434
);
2535
}
2636
if (schema.type === "array") {
@@ -181,7 +191,13 @@ const DynamicJsonForm = ({
181191
const isRequired =
182192
parentSchema?.required?.includes(propertyName || "") ?? false;
183193

184-
switch (propSchema.type) {
194+
let fieldType = propSchema.type;
195+
if (Array.isArray(fieldType)) {
196+
// Of the possible types, find the first non-null type to determine the control to render
197+
fieldType = fieldType.find((t) => t !== "null") ?? fieldType[0];
198+
}
199+
200+
switch (fieldType) {
185201
case "string": {
186202
if (
187203
propSchema.oneOf &&
@@ -337,6 +353,8 @@ const DynamicJsonForm = ({
337353
required={isRequired}
338354
/>
339355
);
356+
case "null":
357+
return null;
340358
case "object":
341359
if (!propSchema.properties) {
342360
return (

client/src/components/ToolsTab.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { TabsContent } from "@/components/ui/tabs";
77
import { Textarea } from "@/components/ui/textarea";
88
import DynamicJsonForm from "./DynamicJsonForm";
99
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
10-
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
10+
import {
11+
generateDefaultValue,
12+
isPropertyRequired,
13+
normalizeUnionType,
14+
} from "@/utils/schemaUtils";
1115
import {
1216
CompatibilityCallToolResult,
1317
ListToolsResult,
@@ -104,7 +108,7 @@ const ToolsTab = ({
104108
</p>
105109
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
106110
([key, value]) => {
107-
const prop = value as JsonSchemaType;
111+
const prop = normalizeUnionType(value as JsonSchemaType);
108112
const inputSchema =
109113
selectedTool.inputSchema as JsonSchemaType;
110114
const required = isPropertyRequired(key, inputSchema);
@@ -148,7 +152,10 @@ const ToolsTab = ({
148152
onChange={(e) =>
149153
setParams({
150154
...params,
151-
[key]: e.target.value,
155+
[key]:
156+
e.target.value === ""
157+
? undefined
158+
: e.target.value,
152159
})
153160
}
154161
className="mt-1"

client/src/components/__tests__/DynamicJsonForm.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ describe("DynamicJsonForm String Fields", () => {
3535
const input = screen.getByRole("textbox");
3636
expect(input).toHaveProperty("type", "text");
3737
});
38+
39+
it("should handle a union type of string and null", () => {
40+
const schema: JsonSchemaType = {
41+
type: ["string", "null"],
42+
description: "Test string or null field",
43+
};
44+
render(
45+
<DynamicJsonForm schema={schema} value={null} onChange={jest.fn()} />,
46+
);
47+
const input = screen.getByRole("textbox");
48+
expect(input).toHaveProperty("type", "text");
49+
});
3850
});
3951

4052
describe("Format Support", () => {

client/src/utils/__tests__/schemaUtils.test.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
generateDefaultValue,
33
formatFieldLabel,
4+
normalizeUnionType,
45
cacheToolOutputSchemas,
56
getToolOutputValidator,
67
validateToolOutput,
@@ -142,6 +143,189 @@ describe("formatFieldLabel", () => {
142143
});
143144
});
144145

146+
describe("normalizeUnionType", () => {
147+
test("normalizes anyOf with string and null to string type", () => {
148+
const schema: JsonSchemaType = {
149+
anyOf: [{ type: "string" }, { type: "null" }],
150+
description: "Optional string parameter",
151+
};
152+
153+
const normalized = normalizeUnionType(schema);
154+
155+
expect(normalized.type).toBe("string");
156+
expect(normalized.anyOf).toBeUndefined();
157+
expect(normalized.description).toBe("Optional string parameter");
158+
});
159+
160+
test("normalizes anyOf with boolean and null to boolean type", () => {
161+
const schema: JsonSchemaType = {
162+
anyOf: [{ type: "boolean" }, { type: "null" }],
163+
description: "Optional boolean parameter",
164+
};
165+
166+
const normalized = normalizeUnionType(schema);
167+
168+
expect(normalized.type).toBe("boolean");
169+
expect(normalized.anyOf).toBeUndefined();
170+
expect(normalized.description).toBe("Optional boolean parameter");
171+
});
172+
173+
test("normalizes array type with string and null to string type", () => {
174+
const schema: JsonSchemaType = {
175+
type: ["string", "null"],
176+
description: "Optional string parameter",
177+
};
178+
179+
const normalized = normalizeUnionType(schema);
180+
181+
expect(normalized.type).toBe("string");
182+
expect(normalized.description).toBe("Optional string parameter");
183+
});
184+
185+
test("normalizes array type with boolean and null to boolean type", () => {
186+
const schema: JsonSchemaType = {
187+
type: ["boolean", "null"],
188+
description: "Optional boolean parameter",
189+
};
190+
191+
const normalized = normalizeUnionType(schema);
192+
193+
expect(normalized.type).toBe("boolean");
194+
expect(normalized.description).toBe("Optional boolean parameter");
195+
});
196+
197+
test("normalizes anyOf with number and null to number type", () => {
198+
const schema: JsonSchemaType = {
199+
anyOf: [{ type: "number" }, { type: "null" }],
200+
description: "Optional number parameter",
201+
};
202+
203+
const normalized = normalizeUnionType(schema);
204+
205+
expect(normalized.type).toBe("number");
206+
expect(normalized.anyOf).toBeUndefined();
207+
expect(normalized.description).toBe("Optional number parameter");
208+
});
209+
210+
test("normalizes anyOf with integer and null to integer type", () => {
211+
const schema: JsonSchemaType = {
212+
anyOf: [{ type: "integer" }, { type: "null" }],
213+
description: "Optional integer parameter",
214+
};
215+
216+
const normalized = normalizeUnionType(schema);
217+
218+
expect(normalized.type).toBe("integer");
219+
expect(normalized.anyOf).toBeUndefined();
220+
expect(normalized.description).toBe("Optional integer parameter");
221+
});
222+
223+
test("normalizes array type with number and null to number type", () => {
224+
const schema: JsonSchemaType = {
225+
type: ["number", "null"],
226+
description: "Optional number parameter",
227+
};
228+
229+
const normalized = normalizeUnionType(schema);
230+
231+
expect(normalized.type).toBe("number");
232+
expect(normalized.description).toBe("Optional number parameter");
233+
});
234+
235+
test("normalizes array type with integer and null to integer type", () => {
236+
const schema: JsonSchemaType = {
237+
type: ["integer", "null"],
238+
description: "Optional integer parameter",
239+
};
240+
241+
const normalized = normalizeUnionType(schema);
242+
243+
expect(normalized.type).toBe("integer");
244+
expect(normalized.description).toBe("Optional integer parameter");
245+
});
246+
247+
test("handles anyOf with reversed order (null first)", () => {
248+
const schema: JsonSchemaType = {
249+
anyOf: [{ type: "null" }, { type: "string" }],
250+
};
251+
252+
const normalized = normalizeUnionType(schema);
253+
254+
expect(normalized.type).toBe("string");
255+
expect(normalized.anyOf).toBeUndefined();
256+
});
257+
258+
test("leaves non-union schemas unchanged", () => {
259+
const schema: JsonSchemaType = {
260+
type: "string",
261+
description: "Regular string parameter",
262+
};
263+
264+
const normalized = normalizeUnionType(schema);
265+
266+
expect(normalized).toEqual(schema);
267+
});
268+
269+
test("leaves anyOf with non-matching types unchanged", () => {
270+
const schema: JsonSchemaType = {
271+
anyOf: [{ type: "string" }, { type: "number" }],
272+
};
273+
274+
const normalized = normalizeUnionType(schema);
275+
276+
expect(normalized).toEqual(schema);
277+
});
278+
279+
test("leaves anyOf with more than two types unchanged", () => {
280+
const schema: JsonSchemaType = {
281+
anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }],
282+
};
283+
284+
const normalized = normalizeUnionType(schema);
285+
286+
expect(normalized).toEqual(schema);
287+
});
288+
289+
test("leaves array type with non-matching types unchanged", () => {
290+
const schema: JsonSchemaType = {
291+
type: ["string", "number"],
292+
};
293+
294+
const normalized = normalizeUnionType(schema);
295+
296+
expect(normalized).toEqual(schema);
297+
});
298+
299+
test("handles schemas without type or anyOf", () => {
300+
const schema: JsonSchemaType = {
301+
description: "Schema without type",
302+
};
303+
304+
const normalized = normalizeUnionType(schema);
305+
306+
expect(normalized).toEqual(schema);
307+
});
308+
309+
test("preserves other properties when normalizing", () => {
310+
const schema: JsonSchemaType = {
311+
anyOf: [{ type: "string" }, { type: "null" }],
312+
description: "Optional string",
313+
minLength: 1,
314+
maxLength: 100,
315+
pattern: "^[a-z]+$",
316+
};
317+
318+
const normalized = normalizeUnionType(schema);
319+
320+
expect(normalized.type).toBe("string");
321+
expect(normalized.anyOf).toBeUndefined();
322+
expect(normalized.description).toBe("Optional string");
323+
expect(normalized.minLength).toBe(1);
324+
expect(normalized.maxLength).toBe(100);
325+
expect(normalized.pattern).toBe("^[a-z]+$");
326+
});
327+
});
328+
145329
describe("Output Schema Validation", () => {
146330
const mockTools: Tool[] = [
147331
{

client/src/utils/jsonUtils.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,16 @@ export type JsonSchemaType = {
2121
| "boolean"
2222
| "array"
2323
| "object"
24-
| "null";
24+
| "null"
25+
| (
26+
| "string"
27+
| "number"
28+
| "integer"
29+
| "boolean"
30+
| "array"
31+
| "object"
32+
| "null"
33+
)[];
2534
title?: string;
2635
description?: string;
2736
required?: string[];

0 commit comments

Comments
 (0)