fix: support FastMCP union types in tool parameter forms
This commit is contained in:
parent
7f1d924ead
commit
f73d8e6f8a
4 changed files with 767 additions and 4 deletions
720
client/src/components/ToolsTab.test.tsx
Normal file
720
client/src/components/ToolsTab.test.tsx
Normal file
|
|
@ -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(
|
||||||
|
<Tabs defaultValue="tools">
|
||||||
|
<ToolsTab {...defaultProps} {...props} />
|
||||||
|
</Tabs>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Tabs defaultValue="tools">
|
||||||
|
<ToolsTab {...defaultProps} selectedTool={mockTools[2]} />
|
||||||
|
</Tabs>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,7 +7,11 @@ import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import DynamicJsonForm from "./DynamicJsonForm";
|
import DynamicJsonForm from "./DynamicJsonForm";
|
||||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
|
import {
|
||||||
|
generateDefaultValue,
|
||||||
|
isPropertyRequired,
|
||||||
|
normalizeUnionType,
|
||||||
|
} from "@/utils/schemaUtils";
|
||||||
import {
|
import {
|
||||||
CompatibilityCallToolResult,
|
CompatibilityCallToolResult,
|
||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
|
|
@ -104,7 +108,7 @@ const ToolsTab = ({
|
||||||
</p>
|
</p>
|
||||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||||
([key, value]) => {
|
([key, value]) => {
|
||||||
const prop = value as JsonSchemaType;
|
const prop = normalizeUnionType(value as JsonSchemaType);
|
||||||
const inputSchema =
|
const inputSchema =
|
||||||
selectedTool.inputSchema as JsonSchemaType;
|
selectedTool.inputSchema as JsonSchemaType;
|
||||||
const required = isPropertyRequired(key, inputSchema);
|
const required = isPropertyRequired(key, inputSchema);
|
||||||
|
|
@ -148,7 +152,10 @@ const ToolsTab = ({
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
[key]: e.target.value,
|
[key]:
|
||||||
|
e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,16 @@ export type JsonSchemaType = {
|
||||||
| "boolean"
|
| "boolean"
|
||||||
| "array"
|
| "array"
|
||||||
| "object"
|
| "object"
|
||||||
| "null";
|
| "null"
|
||||||
|
| (
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "integer"
|
||||||
|
| "boolean"
|
||||||
|
| "array"
|
||||||
|
| "object"
|
||||||
|
| "null"
|
||||||
|
)[];
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
required?: string[];
|
required?: string[];
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,33 @@ export function isPropertyRequired(
|
||||||
return schema.required?.includes(propertyName) ?? false;
|
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
|
* Formats a field key into a human-readable label
|
||||||
* @param key The field key to format
|
* @param key The field key to format
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue