A tool declaring an `outputSchema` returns its real payload in `structuredContent`; the `content[]` blocks usually only summarize it. v1 rendered that payload as its own inspectable JSON section — v2's ToolResultPanel only ever walked `result.content`, so the payload was dropped with no label, section, or hint that it existed. Adds a `StructuredOutputPanel` group: a collapsible "Structured Output" box (bordered like the existing "Resource Links" box) rendering the payload as pretty-printed, syntax-highlighted, copyable JSON, bounded so a large payload scrolls within itself. ToolResultPanel renders it below the content blocks — and also when `content` is empty (where "No results yet" was simply wrong) and below the alert on an error result, so it is never silently dropped. Adds a `list_items` test-server preset (nested payload, the issue's repro shape) and a `structured-output-http.json` showcase config, documented in the README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
223 lines
7.3 KiB
TypeScript
223 lines
7.3 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { CallToolResult } from "@modelcontextprotocol/client";
|
|
import {
|
|
renderWithMantine,
|
|
screen,
|
|
waitFor,
|
|
} from "../../../test/renderWithMantine";
|
|
import { ToolResultPanel } from "./ToolResultPanel";
|
|
import { resultHasResourceLinks } from "./toolResultUtils";
|
|
|
|
const okResult: CallToolResult = {
|
|
content: [{ type: "text", text: "ok" }],
|
|
isError: false,
|
|
};
|
|
|
|
const errorResult: CallToolResult = {
|
|
content: [{ type: "text", text: "boom" }],
|
|
isError: true,
|
|
};
|
|
|
|
const emptyResult: CallToolResult = { content: [] };
|
|
|
|
describe("ToolResultPanel", () => {
|
|
it("renders text content blocks", () => {
|
|
renderWithMantine(<ToolResultPanel result={okResult} onClear={() => {}} />);
|
|
expect(screen.getByText("ok")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders an error alert when isError is true", () => {
|
|
renderWithMantine(
|
|
<ToolResultPanel result={errorResult} onClear={() => {}} />,
|
|
);
|
|
expect(screen.getByText("Tool Error")).toBeInTheDocument();
|
|
expect(screen.getByText("boom")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the empty state when content is empty", () => {
|
|
renderWithMantine(
|
|
<ToolResultPanel result={emptyResult} onClear={() => {}} />,
|
|
);
|
|
expect(screen.getByText("No results yet")).toBeInTheDocument();
|
|
});
|
|
|
|
it("groups resource_link blocks in a scrollable Resource Links box", async () => {
|
|
const user = userEvent.setup();
|
|
const onReadResource = vi.fn().mockResolvedValue({
|
|
contents: [{ uri: "demo://r/1", text: "linked body" }],
|
|
});
|
|
const result: CallToolResult = {
|
|
content: [
|
|
{ type: "text", text: "ok" },
|
|
{ type: "resource_link", uri: "demo://r/1", name: "Linked" },
|
|
],
|
|
};
|
|
renderWithMantine(
|
|
<ToolResultPanel
|
|
result={result}
|
|
onClear={() => {}}
|
|
onReadResource={onReadResource}
|
|
/>,
|
|
);
|
|
expect(screen.getByText("ok")).toBeInTheDocument();
|
|
// The link sits inside a grouped, labeled box.
|
|
expect(
|
|
screen.getByRole("heading", { name: "Resource Links" }),
|
|
).toBeInTheDocument();
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Expand resource demo://r/1" }),
|
|
);
|
|
expect(onReadResource).toHaveBeenCalledWith("demo://r/1");
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/"linked body"/)).toBeInTheDocument(),
|
|
);
|
|
});
|
|
|
|
it("collapses consecutive resource_link blocks into a single box", () => {
|
|
const result: CallToolResult = {
|
|
content: [
|
|
{ type: "text", text: "intro" },
|
|
{ type: "resource_link", uri: "demo://r/1", name: "One" },
|
|
{ type: "resource_link", uri: "demo://r/2", name: "Two" },
|
|
{ type: "resource_link", uri: "demo://r/3", name: "Three" },
|
|
],
|
|
};
|
|
renderWithMantine(<ToolResultPanel result={result} onClear={() => {}} />);
|
|
// One shared "Resource Links" heading for the whole run of links.
|
|
expect(
|
|
screen.getAllByRole("heading", { name: "Resource Links" }),
|
|
).toHaveLength(1);
|
|
expect(screen.getByText("One")).toBeInTheDocument();
|
|
expect(screen.getByText("Two")).toBeInTheDocument();
|
|
expect(screen.getByText("Three")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders a separate Resource Links box per non-adjacent run", () => {
|
|
const result: CallToolResult = {
|
|
content: [
|
|
{ type: "resource_link", uri: "demo://r/1", name: "One" },
|
|
{ type: "text", text: "divider" },
|
|
{ type: "resource_link", uri: "demo://r/2", name: "Two" },
|
|
],
|
|
};
|
|
renderWithMantine(<ToolResultPanel result={result} onClear={() => {}} />);
|
|
// The text block between the two links splits them into two boxes.
|
|
expect(
|
|
screen.getAllByRole("heading", { name: "Resource Links" }),
|
|
).toHaveLength(2);
|
|
expect(screen.getByText("divider")).toBeInTheDocument();
|
|
});
|
|
|
|
describe("structuredContent (#1908)", () => {
|
|
const structured = { items: [{ id: 1, name: "Item A" }], total: 1 };
|
|
|
|
it("renders a Structured Output section alongside the content blocks", () => {
|
|
renderWithMantine(
|
|
<ToolResultPanel
|
|
result={{ ...okResult, structuredContent: structured }}
|
|
onClear={() => {}}
|
|
/>,
|
|
);
|
|
expect(screen.getByText("ok")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("heading", { name: "Structured Output" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("omits the section when the result has no structuredContent", () => {
|
|
renderWithMantine(
|
|
<ToolResultPanel result={okResult} onClear={() => {}} />,
|
|
);
|
|
expect(
|
|
screen.queryByRole("heading", { name: "Structured Output" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the section instead of the empty state when content is empty", () => {
|
|
renderWithMantine(
|
|
<ToolResultPanel
|
|
result={{ content: [], structuredContent: structured }}
|
|
onClear={() => {}}
|
|
/>,
|
|
);
|
|
expect(screen.queryByText("No results yet")).not.toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("heading", { name: "Structured Output" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the section below the alert on an error result", () => {
|
|
renderWithMantine(
|
|
<ToolResultPanel
|
|
result={{ ...errorResult, structuredContent: structured }}
|
|
onClear={() => {}}
|
|
/>,
|
|
);
|
|
expect(screen.getByText("Tool Error")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("heading", { name: "Structured Output" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the section alongside a Resource Links box", () => {
|
|
renderWithMantine(
|
|
<ToolResultPanel
|
|
result={{
|
|
content: [
|
|
{ type: "resource_link", uri: "demo://r/1", name: "One" },
|
|
],
|
|
structuredContent: structured,
|
|
}}
|
|
onClear={() => {}}
|
|
/>,
|
|
);
|
|
expect(
|
|
screen.getByRole("heading", { name: "Resource Links" }),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("heading", { name: "Structured Output" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("invokes onClear when the close button is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
const onClear = vi.fn();
|
|
renderWithMantine(<ToolResultPanel result={okResult} onClear={onClear} />);
|
|
await user.click(screen.getByRole("button", { name: "Close results" }));
|
|
expect(onClear).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
describe("resultHasResourceLinks", () => {
|
|
it("is true only for a non-error result containing a resource_link", () => {
|
|
expect(
|
|
resultHasResourceLinks({
|
|
content: [
|
|
{ type: "text", text: "ok" },
|
|
{ type: "resource_link", uri: "demo://r/1", name: "Linked" },
|
|
],
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("is false for a text-only result", () => {
|
|
expect(resultHasResourceLinks(okResult)).toBe(false);
|
|
});
|
|
|
|
it("is false for an empty result", () => {
|
|
expect(resultHasResourceLinks(emptyResult)).toBe(false);
|
|
});
|
|
|
|
it("is false when the result is an error, even with a resource_link", () => {
|
|
expect(
|
|
resultHasResourceLinks({
|
|
isError: true,
|
|
content: [
|
|
{ type: "resource_link", uri: "demo://r/1", name: "Linked" },
|
|
],
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
});
|