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