From f73d8e6f8a380c69d848c23728333b15e8a5591b Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Sat, 2 Aug 2025 15:59:45 +0200
Subject: [PATCH 1/8] fix: support FastMCP union types in tool parameter forms
---
client/src/components/ToolsTab.test.tsx | 720 ++++++++++++++++++++++++
client/src/components/ToolsTab.tsx | 13 +-
client/src/utils/jsonUtils.ts | 11 +-
client/src/utils/schemaUtils.ts | 27 +
4 files changed, 767 insertions(+), 4 deletions(-)
create mode 100644 client/src/components/ToolsTab.test.tsx
diff --git a/client/src/components/ToolsTab.test.tsx b/client/src/components/ToolsTab.test.tsx
new file mode 100644
index 00000000..cb662d92
--- /dev/null
+++ b/client/src/components/ToolsTab.test.tsx
@@ -0,0 +1,720 @@
+import { render, screen, fireEvent, act } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, jest, beforeEach } from "@jest/globals";
+import ToolsTab from "../ToolsTab";
+import { Tool } from "@modelcontextprotocol/sdk/types.js";
+import { Tabs } from "@/components/ui/tabs";
+import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
+import { within } from "@testing-library/react";
+
+describe("ToolsTab", () => {
+ beforeEach(() => {
+ // Clear the output schema cache before each test
+ cacheToolOutputSchemas([]);
+ });
+
+ const mockTools: Tool[] = [
+ {
+ name: "tool1",
+ description: "First tool",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ num: { type: "number" as const },
+ },
+ },
+ },
+ {
+ name: "tool3",
+ description: "Integer tool",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ count: { type: "integer" as const },
+ },
+ },
+ },
+ {
+ name: "tool2",
+ description: "Second tool",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ num: { type: "number" as const },
+ },
+ },
+ },
+ ];
+
+ const defaultProps = {
+ tools: mockTools,
+ listTools: jest.fn(),
+ clearTools: jest.fn(),
+ callTool: jest.fn(async () => {}),
+ selectedTool: null,
+ setSelectedTool: jest.fn(),
+ toolResult: null,
+ nextCursor: "",
+ error: null,
+ resourceContent: {},
+ onReadResource: jest.fn(),
+ };
+
+ const renderToolsTab = (props = {}) => {
+ return render(
+
+
+ ,
+ );
+ };
+
+ it("should reset input values when switching tools", async () => {
+ const { rerender } = renderToolsTab({
+ selectedTool: mockTools[0],
+ });
+
+ // Enter a value in the first tool's input
+ const input = screen.getByRole("spinbutton") as HTMLInputElement;
+ await act(async () => {
+ fireEvent.change(input, { target: { value: "42" } });
+ });
+ expect(input.value).toBe("42");
+
+ // Switch to second tool
+ rerender(
+
+
+ ,
+ );
+
+ // Verify input is reset
+ const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
+ expect(newInput.value).toBe("");
+ });
+
+ it("should handle integer type inputs", async () => {
+ renderToolsTab({
+ selectedTool: mockTools[1], // Use the tool with integer type
+ });
+
+ const input = screen.getByRole("spinbutton", {
+ name: /count/i,
+ }) as HTMLInputElement;
+ expect(input).toHaveProperty("type", "number");
+ fireEvent.change(input, { target: { value: "42" } });
+ expect(input.value).toBe("42");
+
+ const submitButton = screen.getByRole("button", { name: /run tool/i });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
+ count: 42,
+ });
+ });
+
+ it("should handle union string|null type inputs (like FastMCP optional parameters)", async () => {
+ const unionTool = {
+ name: "searchTool",
+ description: "Search with optional category",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ category: {
+ anyOf: [{ type: "string" as const }, { type: "null" as const }],
+ description: "Optional category parameter",
+ },
+ },
+ required: [],
+ },
+ };
+
+ renderToolsTab({
+ selectedTool: unionTool,
+ });
+
+ const input = screen.getByRole("textbox", {
+ name: /category/i,
+ }) as HTMLTextAreaElement;
+
+ // Initially should be empty string
+ expect(input.value).toBe("");
+
+ // User types "hello"
+ fireEvent.change(input, { target: { value: "hello" } });
+ expect(input.value).toBe("hello");
+
+ const submitButton = screen.getByRole("button", { name: /run tool/i });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Should call with the string value, not null
+ expect(defaultProps.callTool).toHaveBeenCalledWith(unionTool.name, {
+ category: "hello",
+ });
+ });
+
+ it("should omit undefined optional parameters from tool calls", async () => {
+ const unionTool = {
+ name: "searchTool",
+ description: "Search with optional category",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ category: {
+ anyOf: [{ type: "string" as const }, { type: "null" as const }],
+ description: "Optional category parameter",
+ },
+ },
+ required: [],
+ },
+ };
+
+ renderToolsTab({
+ selectedTool: unionTool,
+ });
+
+ const input = screen.getByRole("textbox", {
+ name: /category/i,
+ }) as HTMLTextAreaElement;
+
+ // User types something then deletes it, leaving empty
+ fireEvent.change(input, { target: { value: "hello" } });
+ fireEvent.change(input, { target: { value: "" } });
+ expect(input.value).toBe("");
+
+ const submitButton = screen.getByRole("button", { name: /run tool/i });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Should omit the empty parameter entirely
+ expect(defaultProps.callTool).toHaveBeenCalledWith(unionTool.name, {});
+ });
+
+ it("should allow typing negative numbers", async () => {
+ renderToolsTab({
+ selectedTool: mockTools[0],
+ });
+
+ const input = screen.getByRole("spinbutton") as HTMLInputElement;
+
+ // Complete the negative number
+ fireEvent.change(input, { target: { value: "-42" } });
+ expect(input.value).toBe("-42");
+
+ const submitButton = screen.getByRole("button", { name: /run tool/i });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
+ num: -42,
+ });
+ });
+
+ it("should disable button and change text while tool is running", async () => {
+ // Create a promise that we can resolve later
+ let resolvePromise: ((value: unknown) => void) | undefined;
+ const mockPromise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ // Mock callTool to return our promise
+ const mockCallTool = jest.fn().mockReturnValue(mockPromise);
+
+ renderToolsTab({
+ selectedTool: mockTools[0],
+ callTool: mockCallTool,
+ });
+
+ const submitButton = screen.getByRole("button", { name: /run tool/i });
+ expect(submitButton.getAttribute("disabled")).toBeNull();
+
+ // Click the button and verify immediate state changes
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Verify button is disabled and text changed
+ expect(submitButton.getAttribute("disabled")).not.toBeNull();
+ expect(submitButton.textContent).toBe("Running...");
+
+ // Resolve the promise to simulate tool completion
+ await act(async () => {
+ if (resolvePromise) {
+ await resolvePromise({});
+ }
+ });
+
+ expect(submitButton.getAttribute("disabled")).toBeNull();
+ });
+
+ describe("Output Schema Display", () => {
+ const toolWithOutputSchema: Tool = {
+ name: "weatherTool",
+ description: "Get weather",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ city: { type: "string" as const },
+ },
+ },
+ outputSchema: {
+ type: "object" as const,
+ properties: {
+ temperature: { type: "number" as const },
+ humidity: { type: "number" as const },
+ },
+ required: ["temperature", "humidity"],
+ },
+ };
+
+ it("should display output schema when tool has one", () => {
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ });
+
+ expect(screen.getByText("Output Schema:")).toBeInTheDocument();
+ // Check for expand/collapse button
+ expect(
+ screen.getByRole("button", { name: /expand/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("should not display output schema section when tool doesn't have one", () => {
+ renderToolsTab({
+ selectedTool: mockTools[0], // Tool without outputSchema
+ });
+
+ expect(screen.queryByText("Output Schema:")).not.toBeInTheDocument();
+ });
+
+ it("should toggle output schema expansion", () => {
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ });
+
+ const toggleButton = screen.getByRole("button", { name: /expand/i });
+
+ // Click to expand
+ fireEvent.click(toggleButton);
+ expect(
+ screen.getByRole("button", { name: /collapse/i }),
+ ).toBeInTheDocument();
+
+ // Click to collapse
+ fireEvent.click(toggleButton);
+ expect(
+ screen.getByRole("button", { name: /expand/i }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("Structured Output Results", () => {
+ const toolWithOutputSchema: Tool = {
+ name: "weatherTool",
+ description: "Get weather",
+ inputSchema: {
+ type: "object" as const,
+ properties: {},
+ },
+ outputSchema: {
+ type: "object" as const,
+ properties: {
+ temperature: { type: "number" as const },
+ },
+ required: ["temperature"],
+ },
+ };
+
+ beforeEach(() => {
+ // Cache the tool's output schema before each test
+ cacheToolOutputSchemas([toolWithOutputSchema]);
+ });
+
+ it("should display structured content when present", () => {
+ const structuredResult = {
+ content: [],
+ structuredContent: {
+ temperature: 25,
+ },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: structuredResult,
+ });
+
+ expect(screen.getByText("Structured Content:")).toBeInTheDocument();
+ expect(
+ screen.getByText(/Valid according to output schema/),
+ ).toBeInTheDocument();
+ });
+
+ it("should show validation error for invalid structured content", () => {
+ const invalidResult = {
+ content: [],
+ structuredContent: {
+ temperature: "25", // String instead of number
+ },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: invalidResult,
+ });
+
+ expect(screen.getByText(/Validation Error:/)).toBeInTheDocument();
+ });
+
+ it("should show error when tool with output schema doesn't return structured content", () => {
+ const resultWithoutStructured = {
+ content: [{ type: "text", text: "some result" }],
+ // No structuredContent
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: resultWithoutStructured,
+ });
+
+ expect(
+ screen.getByText(
+ /Tool has an output schema but did not return structured content/,
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("should show unstructured content title when both structured and unstructured exist", () => {
+ const resultWithBoth = {
+ content: [{ type: "text", text: '{"temperature": 25}' }],
+ structuredContent: { temperature: 25 },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: resultWithBoth,
+ });
+
+ expect(screen.getByText("Structured Content:")).toBeInTheDocument();
+ expect(screen.getByText("Unstructured Content:")).toBeInTheDocument();
+ });
+
+ it("should not show unstructured content title when only unstructured exists", () => {
+ const resultWithUnstructuredOnly = {
+ content: [{ type: "text", text: "some result" }],
+ };
+
+ renderToolsTab({
+ selectedTool: mockTools[0], // Tool without output schema
+ toolResult: resultWithUnstructuredOnly,
+ });
+
+ expect(
+ screen.queryByText("Unstructured Content:"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should show compatibility check when tool has output schema", () => {
+ const compatibleResult = {
+ content: [{ type: "text", text: '{"temperature": 25}' }],
+ structuredContent: { temperature: 25 },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: compatibleResult,
+ });
+
+ // Should show compatibility result
+ expect(
+ screen.getByText(/structured content matches/i),
+ ).toBeInTheDocument();
+ });
+
+ it("should accept multiple content blocks with structured output", () => {
+ const multipleBlocksResult = {
+ content: [
+ { type: "text", text: "Here is the weather data:" },
+ { type: "text", text: '{"temperature": 25}' },
+ { type: "text", text: "Have a nice day!" },
+ ],
+ structuredContent: { temperature: 25 },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: multipleBlocksResult,
+ });
+
+ // Should show compatible result with multiple blocks
+ expect(
+ screen.getByText(/structured content matches.*multiple/i),
+ ).toBeInTheDocument();
+ });
+
+ it("should accept mixed content types with structured output", () => {
+ const mixedContentResult = {
+ content: [
+ { type: "text", text: "Weather report:" },
+ { type: "text", text: '{"temperature": 25}' },
+ { type: "image", data: "base64data", mimeType: "image/png" },
+ ],
+ structuredContent: { temperature: 25 },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: mixedContentResult,
+ });
+
+ // Should render without crashing - the validation logic has been updated
+ expect(screen.getAllByText("weatherTool")).toHaveLength(2);
+ });
+
+ it("should reject when no text blocks match structured content", () => {
+ const noMatchResult = {
+ content: [
+ { type: "text", text: "Some text" },
+ { type: "text", text: '{"humidity": 60}' }, // Different structure
+ ],
+ structuredContent: { temperature: 25 },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: noMatchResult,
+ });
+
+ // Should render without crashing - the validation logic has been updated
+ expect(screen.getAllByText("weatherTool")).toHaveLength(2);
+ });
+
+ it("should reject when no text blocks are present", () => {
+ const noTextBlocksResult = {
+ content: [{ type: "image", data: "base64data", mimeType: "image/png" }],
+ structuredContent: { temperature: 25 },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: noTextBlocksResult,
+ });
+
+ // Should render without crashing - the validation logic has been updated
+ expect(screen.getAllByText("weatherTool")).toHaveLength(2);
+ });
+
+ it("should not show compatibility check when tool has no output schema", () => {
+ const resultWithBoth = {
+ content: [{ type: "text", text: '{"data": "value"}' }],
+ structuredContent: { different: "data" },
+ };
+
+ renderToolsTab({
+ selectedTool: mockTools[0], // Tool without output schema
+ toolResult: resultWithBoth,
+ });
+
+ // Should not show any compatibility messages
+ expect(
+ screen.queryByText(
+ /structured content matches|no text blocks|no.*matches/i,
+ ),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Resource Link Content Type", () => {
+ it("should render resource_link content type and handle expansion", async () => {
+ const mockOnReadResource = jest.fn();
+ const resourceContent = {
+ "test://static/resource/1": JSON.stringify({
+ contents: [
+ {
+ uri: "test://static/resource/1",
+ name: "Resource 1",
+ mimeType: "text/plain",
+ text: "Resource 1: This is a plaintext resource",
+ },
+ ],
+ }),
+ };
+
+ const result = {
+ content: [
+ {
+ type: "resource_link",
+ uri: "test://static/resource/1",
+ name: "Resource 1",
+ description: "Resource 1: plaintext resource",
+ mimeType: "text/plain",
+ },
+ {
+ type: "resource_link",
+ uri: "test://static/resource/2",
+ name: "Resource 2",
+ description: "Resource 2: binary blob resource",
+ mimeType: "application/octet-stream",
+ },
+ {
+ type: "resource_link",
+ uri: "test://static/resource/3",
+ name: "Resource 3",
+ description: "Resource 3: plaintext resource",
+ mimeType: "text/plain",
+ },
+ ],
+ };
+
+ renderToolsTab({
+ selectedTool: mockTools[0],
+ toolResult: result,
+ resourceContent,
+ onReadResource: mockOnReadResource,
+ });
+
+ ["1", "2", "3"].forEach((id) => {
+ expect(
+ screen.getByText(`test://static/resource/${id}`),
+ ).toBeInTheDocument();
+ expect(screen.getByText(`Resource ${id}`)).toBeInTheDocument();
+ });
+
+ expect(screen.getAllByText("text/plain")).toHaveLength(2);
+ expect(screen.getByText("application/octet-stream")).toBeInTheDocument();
+
+ const expandButtons = screen.getAllByRole("button", {
+ name: /expand resource/i,
+ });
+ expect(expandButtons).toHaveLength(3);
+ expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
+
+ expandButtons.forEach((button) => {
+ expect(button).toHaveAttribute("aria-expanded", "false");
+ });
+
+ const resource1Button = screen.getByRole("button", {
+ name: /expand resource test:\/\/static\/resource\/1/i,
+ });
+
+ await act(async () => {
+ fireEvent.click(resource1Button);
+ });
+
+ expect(mockOnReadResource).toHaveBeenCalledWith(
+ "test://static/resource/1",
+ );
+ expect(screen.getByText("Resource:")).toBeInTheDocument();
+ expect(document.body).toHaveTextContent("contents:");
+ expect(document.body).toHaveTextContent('uri:"test://static/resource/1"');
+ expect(resource1Button).toHaveAttribute("aria-expanded", "true");
+
+ await act(async () => {
+ fireEvent.click(resource1Button);
+ });
+
+ expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
+ expect(document.body).not.toHaveTextContent("contents:");
+ expect(document.body).not.toHaveTextContent(
+ 'uri:"test://static/resource/1"',
+ );
+ expect(resource1Button).toHaveAttribute("aria-expanded", "false");
+ expect(mockOnReadResource).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Meta Display", () => {
+ const toolWithMeta = {
+ name: "metaTool",
+ description: "Tool with meta",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ foo: { type: "string" as const },
+ },
+ },
+ _meta: {
+ author: "tester",
+ version: 1,
+ },
+ } as unknown as Tool;
+
+ it("should display meta section when tool has _meta", () => {
+ renderToolsTab({
+ tools: [toolWithMeta],
+ selectedTool: toolWithMeta,
+ });
+
+ expect(screen.getByText("Meta:")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /expand/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("should toggle meta expansion", () => {
+ renderToolsTab({
+ tools: [toolWithMeta],
+ selectedTool: toolWithMeta,
+ });
+
+ // There might be multiple Expand buttons (Output Schema, Meta). We need the one within Meta section
+ const metaHeading = screen.getByText("Meta:");
+ const metaContainer = metaHeading.closest("div");
+ expect(metaContainer).toBeTruthy();
+ const toggleButton = within(metaContainer as HTMLElement).getByRole(
+ "button",
+ { name: /expand/i },
+ );
+
+ // Expand Meta
+ fireEvent.click(toggleButton);
+ expect(
+ within(metaContainer as HTMLElement).getByRole("button", {
+ name: /collapse/i,
+ }),
+ ).toBeInTheDocument();
+
+ // Collapse Meta
+ fireEvent.click(toggleButton);
+ expect(
+ within(metaContainer as HTMLElement).getByRole("button", {
+ name: /expand/i,
+ }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("ToolResults Meta", () => {
+ it("should display meta information when present in toolResult", () => {
+ const resultWithMeta = {
+ content: [],
+ _meta: { info: "details", version: 2 },
+ };
+
+ renderToolsTab({
+ selectedTool: mockTools[0],
+ toolResult: resultWithMeta,
+ });
+
+ // Only ToolResults meta should be present since selectedTool has no _meta
+ expect(screen.getAllByText("Meta:")).toHaveLength(1);
+ expect(screen.getByText(/info/i)).toBeInTheDocument();
+ expect(screen.getByText(/version/i)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx
index 2eb682e5..2654feed 100644
--- a/client/src/components/ToolsTab.tsx
+++ b/client/src/components/ToolsTab.tsx
@@ -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 = ({
{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"
diff --git a/client/src/utils/jsonUtils.ts b/client/src/utils/jsonUtils.ts
index 338b642a..60f5b1d6 100644
--- a/client/src/utils/jsonUtils.ts
+++ b/client/src/utils/jsonUtils.ts
@@ -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[];
diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts
index 9fc7a724..d7210a24 100644
--- a/client/src/utils/schemaUtils.ts
+++ b/client/src/utils/schemaUtils.ts
@@ -145,6 +145,33 @@ 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 string and null (FastMCP pattern)
+ if (
+ schema.anyOf &&
+ 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 array type with string and null
+ if (
+ Array.isArray(schema.type) &&
+ schema.type.includes("string") &&
+ schema.type.includes("null")
+ ) {
+ return { ...schema, type: "string" };
+ }
+
+ return schema;
+}
+
/**
* Formats a field key into a human-readable label
* @param key The field key to format
From edd10e48a9a4478ad15d68c767f21e4267bafe6a Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Sat, 2 Aug 2025 16:01:16 +0200
Subject: [PATCH 2/8] fix: updated import path npm test passes now
---
client/src/components/ToolsTab.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/client/src/components/ToolsTab.test.tsx b/client/src/components/ToolsTab.test.tsx
index cb662d92..42c710c9 100644
--- a/client/src/components/ToolsTab.test.tsx
+++ b/client/src/components/ToolsTab.test.tsx
@@ -1,7 +1,7 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach } from "@jest/globals";
-import ToolsTab from "../ToolsTab";
+import ToolsTab from "./ToolsTab";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "@/components/ui/tabs";
import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
From bcba0dc00715a5495a799540588fd8b91ff47cbc Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Sat, 2 Aug 2025 16:11:43 +0200
Subject: [PATCH 3/8] fix: removed tools tab test as its probably overkill and
introduces to much new code
---
client/src/components/ToolsTab.test.tsx | 720 ------------------------
1 file changed, 720 deletions(-)
delete mode 100644 client/src/components/ToolsTab.test.tsx
diff --git a/client/src/components/ToolsTab.test.tsx b/client/src/components/ToolsTab.test.tsx
deleted file mode 100644
index 42c710c9..00000000
--- a/client/src/components/ToolsTab.test.tsx
+++ /dev/null
@@ -1,720 +0,0 @@
-import { render, screen, fireEvent, act } from "@testing-library/react";
-import "@testing-library/jest-dom";
-import { describe, it, jest, beforeEach } from "@jest/globals";
-import ToolsTab from "./ToolsTab";
-import { Tool } from "@modelcontextprotocol/sdk/types.js";
-import { Tabs } from "@/components/ui/tabs";
-import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
-import { within } from "@testing-library/react";
-
-describe("ToolsTab", () => {
- beforeEach(() => {
- // Clear the output schema cache before each test
- cacheToolOutputSchemas([]);
- });
-
- const mockTools: Tool[] = [
- {
- name: "tool1",
- description: "First tool",
- inputSchema: {
- type: "object" as const,
- properties: {
- num: { type: "number" as const },
- },
- },
- },
- {
- name: "tool3",
- description: "Integer tool",
- inputSchema: {
- type: "object" as const,
- properties: {
- count: { type: "integer" as const },
- },
- },
- },
- {
- name: "tool2",
- description: "Second tool",
- inputSchema: {
- type: "object" as const,
- properties: {
- num: { type: "number" as const },
- },
- },
- },
- ];
-
- const defaultProps = {
- tools: mockTools,
- listTools: jest.fn(),
- clearTools: jest.fn(),
- callTool: jest.fn(async () => {}),
- selectedTool: null,
- setSelectedTool: jest.fn(),
- toolResult: null,
- nextCursor: "",
- error: null,
- resourceContent: {},
- onReadResource: jest.fn(),
- };
-
- const renderToolsTab = (props = {}) => {
- return render(
-
-
- ,
- );
- };
-
- it("should reset input values when switching tools", async () => {
- const { rerender } = renderToolsTab({
- selectedTool: mockTools[0],
- });
-
- // Enter a value in the first tool's input
- const input = screen.getByRole("spinbutton") as HTMLInputElement;
- await act(async () => {
- fireEvent.change(input, { target: { value: "42" } });
- });
- expect(input.value).toBe("42");
-
- // Switch to second tool
- rerender(
-
-
- ,
- );
-
- // Verify input is reset
- const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
- expect(newInput.value).toBe("");
- });
-
- it("should handle integer type inputs", async () => {
- renderToolsTab({
- selectedTool: mockTools[1], // Use the tool with integer type
- });
-
- const input = screen.getByRole("spinbutton", {
- name: /count/i,
- }) as HTMLInputElement;
- expect(input).toHaveProperty("type", "number");
- fireEvent.change(input, { target: { value: "42" } });
- expect(input.value).toBe("42");
-
- const submitButton = screen.getByRole("button", { name: /run tool/i });
- await act(async () => {
- fireEvent.click(submitButton);
- });
-
- expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
- count: 42,
- });
- });
-
- it("should handle union string|null type inputs (like FastMCP optional parameters)", async () => {
- const unionTool = {
- name: "searchTool",
- description: "Search with optional category",
- inputSchema: {
- type: "object" as const,
- properties: {
- category: {
- anyOf: [{ type: "string" as const }, { type: "null" as const }],
- description: "Optional category parameter",
- },
- },
- required: [],
- },
- };
-
- renderToolsTab({
- selectedTool: unionTool,
- });
-
- const input = screen.getByRole("textbox", {
- name: /category/i,
- }) as HTMLTextAreaElement;
-
- // Initially should be empty string
- expect(input.value).toBe("");
-
- // User types "hello"
- fireEvent.change(input, { target: { value: "hello" } });
- expect(input.value).toBe("hello");
-
- const submitButton = screen.getByRole("button", { name: /run tool/i });
- await act(async () => {
- fireEvent.click(submitButton);
- });
-
- // Should call with the string value, not null
- expect(defaultProps.callTool).toHaveBeenCalledWith(unionTool.name, {
- category: "hello",
- });
- });
-
- it("should omit undefined optional parameters from tool calls", async () => {
- const unionTool = {
- name: "searchTool",
- description: "Search with optional category",
- inputSchema: {
- type: "object" as const,
- properties: {
- category: {
- anyOf: [{ type: "string" as const }, { type: "null" as const }],
- description: "Optional category parameter",
- },
- },
- required: [],
- },
- };
-
- renderToolsTab({
- selectedTool: unionTool,
- });
-
- const input = screen.getByRole("textbox", {
- name: /category/i,
- }) as HTMLTextAreaElement;
-
- // User types something then deletes it, leaving empty
- fireEvent.change(input, { target: { value: "hello" } });
- fireEvent.change(input, { target: { value: "" } });
- expect(input.value).toBe("");
-
- const submitButton = screen.getByRole("button", { name: /run tool/i });
- await act(async () => {
- fireEvent.click(submitButton);
- });
-
- // Should omit the empty parameter entirely
- expect(defaultProps.callTool).toHaveBeenCalledWith(unionTool.name, {});
- });
-
- it("should allow typing negative numbers", async () => {
- renderToolsTab({
- selectedTool: mockTools[0],
- });
-
- const input = screen.getByRole("spinbutton") as HTMLInputElement;
-
- // Complete the negative number
- fireEvent.change(input, { target: { value: "-42" } });
- expect(input.value).toBe("-42");
-
- const submitButton = screen.getByRole("button", { name: /run tool/i });
- await act(async () => {
- fireEvent.click(submitButton);
- });
-
- expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
- num: -42,
- });
- });
-
- it("should disable button and change text while tool is running", async () => {
- // Create a promise that we can resolve later
- let resolvePromise: ((value: unknown) => void) | undefined;
- const mockPromise = new Promise((resolve) => {
- resolvePromise = resolve;
- });
-
- // Mock callTool to return our promise
- const mockCallTool = jest.fn().mockReturnValue(mockPromise);
-
- renderToolsTab({
- selectedTool: mockTools[0],
- callTool: mockCallTool,
- });
-
- const submitButton = screen.getByRole("button", { name: /run tool/i });
- expect(submitButton.getAttribute("disabled")).toBeNull();
-
- // Click the button and verify immediate state changes
- await act(async () => {
- fireEvent.click(submitButton);
- });
-
- // Verify button is disabled and text changed
- expect(submitButton.getAttribute("disabled")).not.toBeNull();
- expect(submitButton.textContent).toBe("Running...");
-
- // Resolve the promise to simulate tool completion
- await act(async () => {
- if (resolvePromise) {
- await resolvePromise({});
- }
- });
-
- expect(submitButton.getAttribute("disabled")).toBeNull();
- });
-
- describe("Output Schema Display", () => {
- const toolWithOutputSchema: Tool = {
- name: "weatherTool",
- description: "Get weather",
- inputSchema: {
- type: "object" as const,
- properties: {
- city: { type: "string" as const },
- },
- },
- outputSchema: {
- type: "object" as const,
- properties: {
- temperature: { type: "number" as const },
- humidity: { type: "number" as const },
- },
- required: ["temperature", "humidity"],
- },
- };
-
- it("should display output schema when tool has one", () => {
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- });
-
- expect(screen.getByText("Output Schema:")).toBeInTheDocument();
- // Check for expand/collapse button
- expect(
- screen.getByRole("button", { name: /expand/i }),
- ).toBeInTheDocument();
- });
-
- it("should not display output schema section when tool doesn't have one", () => {
- renderToolsTab({
- selectedTool: mockTools[0], // Tool without outputSchema
- });
-
- expect(screen.queryByText("Output Schema:")).not.toBeInTheDocument();
- });
-
- it("should toggle output schema expansion", () => {
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- });
-
- const toggleButton = screen.getByRole("button", { name: /expand/i });
-
- // Click to expand
- fireEvent.click(toggleButton);
- expect(
- screen.getByRole("button", { name: /collapse/i }),
- ).toBeInTheDocument();
-
- // Click to collapse
- fireEvent.click(toggleButton);
- expect(
- screen.getByRole("button", { name: /expand/i }),
- ).toBeInTheDocument();
- });
- });
-
- describe("Structured Output Results", () => {
- const toolWithOutputSchema: Tool = {
- name: "weatherTool",
- description: "Get weather",
- inputSchema: {
- type: "object" as const,
- properties: {},
- },
- outputSchema: {
- type: "object" as const,
- properties: {
- temperature: { type: "number" as const },
- },
- required: ["temperature"],
- },
- };
-
- beforeEach(() => {
- // Cache the tool's output schema before each test
- cacheToolOutputSchemas([toolWithOutputSchema]);
- });
-
- it("should display structured content when present", () => {
- const structuredResult = {
- content: [],
- structuredContent: {
- temperature: 25,
- },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: structuredResult,
- });
-
- expect(screen.getByText("Structured Content:")).toBeInTheDocument();
- expect(
- screen.getByText(/Valid according to output schema/),
- ).toBeInTheDocument();
- });
-
- it("should show validation error for invalid structured content", () => {
- const invalidResult = {
- content: [],
- structuredContent: {
- temperature: "25", // String instead of number
- },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: invalidResult,
- });
-
- expect(screen.getByText(/Validation Error:/)).toBeInTheDocument();
- });
-
- it("should show error when tool with output schema doesn't return structured content", () => {
- const resultWithoutStructured = {
- content: [{ type: "text", text: "some result" }],
- // No structuredContent
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: resultWithoutStructured,
- });
-
- expect(
- screen.getByText(
- /Tool has an output schema but did not return structured content/,
- ),
- ).toBeInTheDocument();
- });
-
- it("should show unstructured content title when both structured and unstructured exist", () => {
- const resultWithBoth = {
- content: [{ type: "text", text: '{"temperature": 25}' }],
- structuredContent: { temperature: 25 },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: resultWithBoth,
- });
-
- expect(screen.getByText("Structured Content:")).toBeInTheDocument();
- expect(screen.getByText("Unstructured Content:")).toBeInTheDocument();
- });
-
- it("should not show unstructured content title when only unstructured exists", () => {
- const resultWithUnstructuredOnly = {
- content: [{ type: "text", text: "some result" }],
- };
-
- renderToolsTab({
- selectedTool: mockTools[0], // Tool without output schema
- toolResult: resultWithUnstructuredOnly,
- });
-
- expect(
- screen.queryByText("Unstructured Content:"),
- ).not.toBeInTheDocument();
- });
-
- it("should show compatibility check when tool has output schema", () => {
- const compatibleResult = {
- content: [{ type: "text", text: '{"temperature": 25}' }],
- structuredContent: { temperature: 25 },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: compatibleResult,
- });
-
- // Should show compatibility result
- expect(
- screen.getByText(/structured content matches/i),
- ).toBeInTheDocument();
- });
-
- it("should accept multiple content blocks with structured output", () => {
- const multipleBlocksResult = {
- content: [
- { type: "text", text: "Here is the weather data:" },
- { type: "text", text: '{"temperature": 25}' },
- { type: "text", text: "Have a nice day!" },
- ],
- structuredContent: { temperature: 25 },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: multipleBlocksResult,
- });
-
- // Should show compatible result with multiple blocks
- expect(
- screen.getByText(/structured content matches.*multiple/i),
- ).toBeInTheDocument();
- });
-
- it("should accept mixed content types with structured output", () => {
- const mixedContentResult = {
- content: [
- { type: "text", text: "Weather report:" },
- { type: "text", text: '{"temperature": 25}' },
- { type: "image", data: "base64data", mimeType: "image/png" },
- ],
- structuredContent: { temperature: 25 },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: mixedContentResult,
- });
-
- // Should render without crashing - the validation logic has been updated
- expect(screen.getAllByText("weatherTool")).toHaveLength(2);
- });
-
- it("should reject when no text blocks match structured content", () => {
- const noMatchResult = {
- content: [
- { type: "text", text: "Some text" },
- { type: "text", text: '{"humidity": 60}' }, // Different structure
- ],
- structuredContent: { temperature: 25 },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: noMatchResult,
- });
-
- // Should render without crashing - the validation logic has been updated
- expect(screen.getAllByText("weatherTool")).toHaveLength(2);
- });
-
- it("should reject when no text blocks are present", () => {
- const noTextBlocksResult = {
- content: [{ type: "image", data: "base64data", mimeType: "image/png" }],
- structuredContent: { temperature: 25 },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: noTextBlocksResult,
- });
-
- // Should render without crashing - the validation logic has been updated
- expect(screen.getAllByText("weatherTool")).toHaveLength(2);
- });
-
- it("should not show compatibility check when tool has no output schema", () => {
- const resultWithBoth = {
- content: [{ type: "text", text: '{"data": "value"}' }],
- structuredContent: { different: "data" },
- };
-
- renderToolsTab({
- selectedTool: mockTools[0], // Tool without output schema
- toolResult: resultWithBoth,
- });
-
- // Should not show any compatibility messages
- expect(
- screen.queryByText(
- /structured content matches|no text blocks|no.*matches/i,
- ),
- ).not.toBeInTheDocument();
- });
- });
-
- describe("Resource Link Content Type", () => {
- it("should render resource_link content type and handle expansion", async () => {
- const mockOnReadResource = jest.fn();
- const resourceContent = {
- "test://static/resource/1": JSON.stringify({
- contents: [
- {
- uri: "test://static/resource/1",
- name: "Resource 1",
- mimeType: "text/plain",
- text: "Resource 1: This is a plaintext resource",
- },
- ],
- }),
- };
-
- const result = {
- content: [
- {
- type: "resource_link",
- uri: "test://static/resource/1",
- name: "Resource 1",
- description: "Resource 1: plaintext resource",
- mimeType: "text/plain",
- },
- {
- type: "resource_link",
- uri: "test://static/resource/2",
- name: "Resource 2",
- description: "Resource 2: binary blob resource",
- mimeType: "application/octet-stream",
- },
- {
- type: "resource_link",
- uri: "test://static/resource/3",
- name: "Resource 3",
- description: "Resource 3: plaintext resource",
- mimeType: "text/plain",
- },
- ],
- };
-
- renderToolsTab({
- selectedTool: mockTools[0],
- toolResult: result,
- resourceContent,
- onReadResource: mockOnReadResource,
- });
-
- ["1", "2", "3"].forEach((id) => {
- expect(
- screen.getByText(`test://static/resource/${id}`),
- ).toBeInTheDocument();
- expect(screen.getByText(`Resource ${id}`)).toBeInTheDocument();
- });
-
- expect(screen.getAllByText("text/plain")).toHaveLength(2);
- expect(screen.getByText("application/octet-stream")).toBeInTheDocument();
-
- const expandButtons = screen.getAllByRole("button", {
- name: /expand resource/i,
- });
- expect(expandButtons).toHaveLength(3);
- expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
-
- expandButtons.forEach((button) => {
- expect(button).toHaveAttribute("aria-expanded", "false");
- });
-
- const resource1Button = screen.getByRole("button", {
- name: /expand resource test:\/\/static\/resource\/1/i,
- });
-
- await act(async () => {
- fireEvent.click(resource1Button);
- });
-
- expect(mockOnReadResource).toHaveBeenCalledWith(
- "test://static/resource/1",
- );
- expect(screen.getByText("Resource:")).toBeInTheDocument();
- expect(document.body).toHaveTextContent("contents:");
- expect(document.body).toHaveTextContent('uri:"test://static/resource/1"');
- expect(resource1Button).toHaveAttribute("aria-expanded", "true");
-
- await act(async () => {
- fireEvent.click(resource1Button);
- });
-
- expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
- expect(document.body).not.toHaveTextContent("contents:");
- expect(document.body).not.toHaveTextContent(
- 'uri:"test://static/resource/1"',
- );
- expect(resource1Button).toHaveAttribute("aria-expanded", "false");
- expect(mockOnReadResource).toHaveBeenCalledTimes(1);
- });
- });
-
- describe("Meta Display", () => {
- const toolWithMeta = {
- name: "metaTool",
- description: "Tool with meta",
- inputSchema: {
- type: "object" as const,
- properties: {
- foo: { type: "string" as const },
- },
- },
- _meta: {
- author: "tester",
- version: 1,
- },
- } as unknown as Tool;
-
- it("should display meta section when tool has _meta", () => {
- renderToolsTab({
- tools: [toolWithMeta],
- selectedTool: toolWithMeta,
- });
-
- expect(screen.getByText("Meta:")).toBeInTheDocument();
- expect(
- screen.getByRole("button", { name: /expand/i }),
- ).toBeInTheDocument();
- });
-
- it("should toggle meta expansion", () => {
- renderToolsTab({
- tools: [toolWithMeta],
- selectedTool: toolWithMeta,
- });
-
- // There might be multiple Expand buttons (Output Schema, Meta). We need the one within Meta section
- const metaHeading = screen.getByText("Meta:");
- const metaContainer = metaHeading.closest("div");
- expect(metaContainer).toBeTruthy();
- const toggleButton = within(metaContainer as HTMLElement).getByRole(
- "button",
- { name: /expand/i },
- );
-
- // Expand Meta
- fireEvent.click(toggleButton);
- expect(
- within(metaContainer as HTMLElement).getByRole("button", {
- name: /collapse/i,
- }),
- ).toBeInTheDocument();
-
- // Collapse Meta
- fireEvent.click(toggleButton);
- expect(
- within(metaContainer as HTMLElement).getByRole("button", {
- name: /expand/i,
- }),
- ).toBeInTheDocument();
- });
- });
-
- describe("ToolResults Meta", () => {
- it("should display meta information when present in toolResult", () => {
- const resultWithMeta = {
- content: [],
- _meta: { info: "details", version: 2 },
- };
-
- renderToolsTab({
- selectedTool: mockTools[0],
- toolResult: resultWithMeta,
- });
-
- // Only ToolResults meta should be present since selectedTool has no _meta
- expect(screen.getAllByText("Meta:")).toHaveLength(1);
- expect(screen.getByText(/info/i)).toBeInTheDocument();
- expect(screen.getByText(/version/i)).toBeInTheDocument();
- });
- });
-});
From 5469993e2784200ad7efe57049949e164381068d Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Sat, 2 Aug 2025 16:35:42 +0200
Subject: [PATCH 4/8] fix: add boolean union type support for FastMCP optional
booleans
---
client/src/utils/schemaUtils.ts | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts
index d7210a24..62401bf8 100644
--- a/client/src/utils/schemaUtils.ts
+++ b/client/src/utils/schemaUtils.ts
@@ -160,6 +160,15 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
return { ...schema, type: "string", anyOf: undefined };
}
+ // Handle anyOf with boolean and null (FastMCP pattern)
+ if (
+ schema.anyOf &&
+ 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 array type with string and null
if (
Array.isArray(schema.type) &&
@@ -169,6 +178,15 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
return { ...schema, type: "string" };
}
+ // Handle array type with boolean and null
+ if (
+ Array.isArray(schema.type) &&
+ schema.type.includes("boolean") &&
+ schema.type.includes("null")
+ ) {
+ return { ...schema, type: "boolean" };
+ }
+
return schema;
}
From 1d960a1b9a6092b9d3086ed9165c7a6dc5855c34 Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Sun, 3 Aug 2025 10:34:19 +0200
Subject: [PATCH 5/8] fix: added explicit handling for float & int
---
client/src/utils/schemaUtils.ts | 52 ++++++++++++++++++++++++++++++---
1 file changed, 48 insertions(+), 4 deletions(-)
diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts
index 62401bf8..7a175142 100644
--- a/client/src/utils/schemaUtils.ts
+++ b/client/src/utils/schemaUtils.ts
@@ -151,42 +151,86 @@ export function isPropertyRequired(
* @returns A normalized schema or the original schema
*/
export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
- // Handle anyOf with string and null (FastMCP pattern)
+ // 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 boolean and null (FastMCP pattern)
+ // 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 array type with string and null
+ // 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 boolean and null
+ // 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;
}
From d4f8e061f3dfe382d9b015b3022ec39835fd0330 Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Sun, 3 Aug 2025 10:34:40 +0200
Subject: [PATCH 6/8] ci: added tests for the union type
---
.../src/utils/__tests__/schemaUtils.test.ts | 184 ++++++++++++++++++
1 file changed, 184 insertions(+)
diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts
index 133e25ec..0ecd8075 100644
--- a/client/src/utils/__tests__/schemaUtils.test.ts
+++ b/client/src/utils/__tests__/schemaUtils.test.ts
@@ -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[] = [
{
From cc3f9df33c1cec26b9d68d5f4d2e7411f9535427 Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Mon, 4 Aug 2025 09:26:24 +0200
Subject: [PATCH 7/8] fix: fix type error by handling array of types in JSON
schema validation
---
client/src/components/DynamicJsonForm.tsx | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx
index 849a2aa2..aafa2933 100644
--- a/client/src/components/DynamicJsonForm.tsx
+++ b/client/src/components/DynamicJsonForm.tsx
@@ -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") {
From fda879ccc1d5488402012d5f615602ff18c9c291 Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Mon, 4 Aug 2025 09:32:24 +0200
Subject: [PATCH 8/8] ci: added test for the DynamicJsonForm
---
client/src/components/DynamicJsonForm.tsx | 10 +++++++++-
.../components/__tests__/DynamicJsonForm.test.tsx | 12 ++++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx
index aafa2933..2214add9 100644
--- a/client/src/components/DynamicJsonForm.tsx
+++ b/client/src/components/DynamicJsonForm.tsx
@@ -191,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 &&
@@ -347,6 +353,8 @@ const DynamicJsonForm = ({
required={isRequired}
/>
);
+ case "null":
+ return null;
case "object":
if (!propSchema.properties) {
return (
diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx
index 22813c9b..318fba2d 100644
--- a/client/src/components/__tests__/DynamicJsonForm.test.tsx
+++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx
@@ -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(
+ ,
+ );
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveProperty("type", "text");
+ });
});
describe("Format Support", () => {