import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { Tool } from "@modelcontextprotocol/client"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { ToolDetailPanel } from "./ToolDetailPanel"; const simpleTool: Tool = { name: "send_message", inputSchema: { type: "object", properties: { message: { type: "string", description: "The message to send" }, }, required: ["message"], }, }; const titledTool: Tool = { name: "send_message", title: "Send Message", description: "Sends a message to the recipient", inputSchema: { type: "object", properties: { message: { type: "string" }, }, }, }; const ICON_SRC = "data:image/svg+xml,%3Csvg/%3E"; const iconedTool: Tool = { name: "send_message", title: "Send Message", icons: [{ src: ICON_SRC }], inputSchema: { type: "object", properties: {}, }, }; const annotatedTool: Tool = { name: "delete_records", description: "Deletes records", annotations: { readOnlyHint: true, destructiveHint: true, idempotentHint: true, openWorldHint: true, }, inputSchema: { type: "object", properties: { pattern: { type: "string" }, }, }, }; const baseProps = { formValues: {}, isExecuting: false, serverSupportsTaskToolCalls: false, runAsTask: false, onRunAsTaskChange: vi.fn(), onFormChange: vi.fn(), onExecute: vi.fn(), onCancel: vi.fn(), }; describe("ToolDetailPanel", () => { it("renders the tool name when no title is provided", () => { renderWithMantine(); expect(screen.getByText("send_message")).toBeInTheDocument(); }); it("prefers the title over the name when title is provided", () => { renderWithMantine(); expect(screen.getByText("Send Message")).toBeInTheDocument(); expect( screen.getByText("Sends a message to the recipient"), ).toBeInTheDocument(); }); it("renders the first icon when tool.icons is present", () => { renderWithMantine(); const img = screen.getByRole("presentation"); expect(img).toHaveAttribute("src", ICON_SRC); }); it("does not render an icon when tool.icons is missing", () => { renderWithMantine(); expect(screen.queryByRole("presentation")).not.toBeInTheDocument(); }); it("does not render the description when none is provided", () => { renderWithMantine(); expect( screen.queryByText("Sends a message to the recipient"), ).not.toBeInTheDocument(); }); describe("Collapsible description", () => { it("renders the description shown by default (Hide description toggle)", () => { renderWithMantine(); const toggle = screen.getByRole("button", { name: "Hide description" }); expect(toggle).toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Show description" }), ).not.toBeInTheDocument(); }); it("hides the description when the toggle is clicked", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click( screen.getByRole("button", { name: "Hide description" }), ); expect( screen.getByRole("button", { name: "Show description" }), ).toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Hide description" }), ).not.toBeInTheDocument(); }); it("shows the description again when the collapsed toggle is clicked", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click( screen.getByRole("button", { name: "Hide description" }), ); await user.click( screen.getByRole("button", { name: "Show description" }), ); expect( screen.getByRole("button", { name: "Hide description" }), ).toBeInTheDocument(); }); it("resets to shown when switching to a different tool", async () => { const user = userEvent.setup(); const { rerender } = renderWithMantine( , ); await user.click( screen.getByRole("button", { name: "Hide description" }), ); expect( screen.getByRole("button", { name: "Show description" }), ).toBeInTheDocument(); // Switching tools (different name) should reset the local hidden state. rerender(); expect( screen.getByRole("button", { name: "Hide description" }), ).toBeInTheDocument(); }); it("wires the toggle as an expandable control (aria-expanded + aria-controls)", async () => { const user = userEvent.setup(); renderWithMantine(); const toggle = screen.getByRole("button", { name: "Hide description" }); expect(toggle).toHaveAttribute("aria-expanded", "true"); // aria-controls points at the Collapse region holding the description. const regionId = toggle.getAttribute("aria-controls"); expect(regionId).toBeTruthy(); expect(document.getElementById(regionId as string)).toContainElement( screen.getByText("Sends a message to the recipient"), ); await user.click(toggle); expect( screen.getByRole("button", { name: "Show description" }), ).toHaveAttribute("aria-expanded", "false"); }); it("does not render a description toggle when the tool has no description", () => { renderWithMantine(); expect( screen.queryByRole("button", { name: "Show description" }), ).not.toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Hide description" }), ).not.toBeInTheDocument(); }); }); it("renders all annotation badges when annotations are present", () => { renderWithMantine(); expect(screen.getByText("read-only")).toBeInTheDocument(); expect(screen.getByText("destructive")).toBeInTheDocument(); expect(screen.getByText("idempotent")).toBeInTheDocument(); expect(screen.getByText("open-world")).toBeInTheDocument(); }); it("does not render annotation row when no annotation flags are set", () => { renderWithMantine(); expect(screen.queryByText("read-only")).not.toBeInTheDocument(); expect(screen.queryByText("destructive")).not.toBeInTheDocument(); }); it("does not render annotation row when annotations object is empty", () => { const toolWithEmptyAnnotations: Tool = { name: "no_hints", annotations: { title: "Does not matter" }, inputSchema: { type: "object", properties: {}, }, }; renderWithMantine( , ); expect(screen.queryByText("read-only")).not.toBeInTheDocument(); expect(screen.queryByText("destructive")).not.toBeInTheDocument(); expect(screen.queryByText("idempotent")).not.toBeInTheDocument(); expect(screen.queryByText("open-world")).not.toBeInTheDocument(); }); it("renders the Execute Tool button enabled when not executing", () => { renderWithMantine( , ); const button = screen.getByRole("button", { name: "Execute Tool" }); expect(button).toBeInTheDocument(); expect(button).not.toBeDisabled(); }); it("invokes onExecute when Execute Tool is clicked", async () => { const user = userEvent.setup(); const onExecute = vi.fn(); renderWithMantine( , ); await user.click(screen.getByRole("button", { name: "Execute Tool" })); expect(onExecute).toHaveBeenCalledTimes(1); }); it("disables the Execute Tool button while executing and renders Cancel", () => { renderWithMantine( , ); expect(screen.getByRole("button", { name: "Execute Tool" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); }); it("invokes onCancel when Cancel is clicked", async () => { const user = userEvent.setup(); const onCancel = vi.fn(); renderWithMantine( , ); await user.click(screen.getByRole("button", { name: "Cancel" })); expect(onCancel).toHaveBeenCalledTimes(1); }); it("renders progress display when progress is provided", () => { renderWithMantine( , ); expect(screen.getByText("Step 3 of 5")).toBeInTheDocument(); expect(screen.getByText("60%")).toBeInTheDocument(); }); it("renders the schema form using the tool's inputSchema", () => { renderWithMantine(); // The string field 'message' should render with its description expect(screen.getByText("The message to send")).toBeInTheDocument(); }); it("invokes onFormChange when the user types in a form field", async () => { const user = userEvent.setup(); const onFormChange = vi.fn(); renderWithMantine( , ); const input = screen.getByRole("textbox"); await user.type(input, "hi"); expect(onFormChange).toHaveBeenCalled(); }); describe("Run as task toggle", () => { const toolWithSupport = ( taskSupport: "forbidden" | "optional" | "required", ): Tool => ({ name: "run_job", execution: { taskSupport }, inputSchema: { type: "object", properties: {} }, }); it("hides the toggle when the server doesn't support task tool calls", () => { renderWithMantine( , ); expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument(); }); it("hides the toggle for a task-forbidden tool even when the server supports tasks", () => { renderWithMantine( , ); expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument(); }); it("shows the toggle for a task-forbidden tool on a modern connection (#1631)", () => { // On modern (SEP-2663) task creation is server-directed, so any tool can // become a task — the toggle is offered regardless of per-tool taskSupport. renderWithMantine( , ); const toggle = screen.getByLabelText("Run as task"); expect(toggle).not.toBeChecked(); expect(toggle).not.toBeDisabled(); }); it("routes the user's runAsTask choice on a modern connection (#1631)", async () => { const user = userEvent.setup(); const onExecute = vi.fn(); renderWithMantine( , ); // The toggle is checked (user's choice), and executing forwards true. expect(screen.getByLabelText("Run as task")).toBeChecked(); await user.click(screen.getByRole("button", { name: "Execute Tool" })); expect(onExecute).toHaveBeenCalledWith(true); }); it("shows an unchecked, enabled toggle for an optional tool (off by default)", () => { renderWithMantine( , ); const toggle = screen.getByLabelText("Run as task"); expect(toggle).not.toBeChecked(); expect(toggle).not.toBeDisabled(); }); it("reflects the runAsTask prop for an optional tool", () => { renderWithMantine( , ); expect(screen.getByLabelText("Run as task")).toBeChecked(); }); it("forces the toggle on and disabled for a required tool", () => { renderWithMantine( , ); const toggle = screen.getByLabelText("Run as task"); expect(toggle).toBeChecked(); expect(toggle).toBeDisabled(); }); it("invokes onRunAsTaskChange when an optional toggle is clicked", async () => { const user = userEvent.setup(); const onRunAsTaskChange = vi.fn(); renderWithMantine( , ); await user.click(screen.getByLabelText("Run as task")); expect(onRunAsTaskChange).toHaveBeenCalledWith(true); }); it("passes the effective run-as-task decision to onExecute", async () => { const user = userEvent.setup(); const onExecute = vi.fn(); // optional + runAsTask=true → effective true renderWithMantine( , ); await user.click(screen.getByRole("button", { name: "Execute Tool" })); expect(onExecute).toHaveBeenCalledWith(true); }); it("passes runAsTask=true to onExecute for a required tool regardless of the prop", async () => { const user = userEvent.setup(); const onExecute = vi.fn(); renderWithMantine( , ); await user.click(screen.getByRole("button", { name: "Execute Tool" })); expect(onExecute).toHaveBeenCalledWith(true); }); it("gates onExecute to false when the server lacks task-tool-call support, even with a stale runAsTask", async () => { const user = userEvent.setup(); const onExecute = vi.fn(); // The toggle is hidden (server doesn't support it), but a leftover // runAsTask=true must not leak into the effective decision. renderWithMantine( , ); expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Execute Tool" })); expect(onExecute).toHaveBeenCalledWith(false); }); }); describe("mirrored request headers (SEP-2243, #1632)", () => { const mirroredTool: Tool = { name: "get_weather", inputSchema: { type: "object", properties: { city: { type: "string", "x-mcp-header": "City" }, country: { type: "string", "x-mcp-header": "Country" }, }, }, }; it("lists each mirrored arg and its Mcp-Param header", () => { renderWithMantine(); expect( screen.getByText("Mirrored request headers (SEP-2243)"), ).toBeInTheDocument(); // The header names are unique to this section (the arg names `city` / // `country` also appear as form-field labels below). expect(screen.getByText("Mcp-Param-City")).toBeInTheDocument(); expect(screen.getByText("Mcp-Param-Country")).toBeInTheDocument(); // The arg path renders in a alongside its header. expect(screen.getAllByText("city").length).toBeGreaterThanOrEqual(1); }); it("omits the section for a tool without x-mcp-header annotations", () => { renderWithMantine(); expect( screen.queryByText("Mirrored request headers (SEP-2243)"), ).not.toBeInTheDocument(); }); }); describe("schema portability findings (#1005)", () => { it("lists a finding with its path when a schema is unportable", () => { renderWithMantine( , ); expect(screen.getByText("Schema portability (1)")).toBeInTheDocument(); expect( screen.getByText("outputSchema.properties.data"), ).toBeInTheDocument(); }); it("omits the section for a portable tool", () => { renderWithMantine(); expect(screen.queryByTestId("schema-findings")).not.toBeInTheDocument(); }); }); // #2020: Execute used to be gated on `isExecuting` alone — this panel never // called `hasMissingRequiredFields`, unlike the App and elicitation panels — // so arguments that could not be sent were executed anyway and simply arrived // absent at the server. describe("submit gating (#2020)", () => { const optionalJsonTool: Tool = { name: "record_shipment", inputSchema: { type: "object", // An array of no declared item type renders through the JSON editor, // which is where a draft can fail to parse. properties: { payload: { type: "array", title: "Payload" } }, }, }; it("disables Execute while a required argument has no value", () => { renderWithMantine( , ); expect( screen.getByRole("button", { name: "Execute Tool" }), ).toBeDisabled(); }); it("enables Execute once the required argument is filled", () => { renderWithMantine( , ); expect( screen.getByRole("button", { name: "Execute Tool" }), ).not.toBeDisabled(); }); it("disables Execute while an optional field holds text it cannot send", async () => { const user = userEvent.setup(); renderWithMantine( , ); const execute = screen.getByRole("button", { name: "Execute Tool" }); expect(execute).not.toBeDisabled(); // The draft never reaches `formValues` — the field reports `undefined` // for text it cannot parse — so only the form's validity channel makes // this visible to the gate. const jsonInput = screen.getByLabelText(/Payload/); await user.type(jsonInput, "x"); expect(execute).toBeDisabled(); await user.clear(jsonInput); expect(execute).not.toBeDisabled(); }); }); });