fix: render a tool result's structuredContent in the Tools screen (#1908)
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
This commit is contained in:
parent
a09f2dcf1d
commit
6f47eaa143
10 changed files with 465 additions and 9 deletions
|
|
@ -138,6 +138,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
|
||||||
| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) |
|
| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) |
|
||||||
| `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) |
|
| `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) |
|
||||||
| `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) |
|
| `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) |
|
||||||
|
| `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) |
|
||||||
| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) |
|
| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) |
|
||||||
| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
|
| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
|
||||||
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
|
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
|
||||||
|
|
@ -206,6 +207,12 @@ Under SDK v2 a `tools/call` rejecting with `-32602` renders as a distinct error
|
||||||
|
|
||||||
Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the lists load page 1 only (4 items) with a **Load next page** control and an _N pages loaded_ status. Each click fetches the next 4 and appends them; Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect.
|
Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the lists load page 1 only (4 items) with a **Load next page** control and an _N pages loaded_ status. Each click fetches the next 4 and appends them; Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect.
|
||||||
|
|
||||||
|
#### Structured output
|
||||||
|
|
||||||
|
`structured-output-http.json` serves `list_items` (nested `structuredContent` — objects inside arrays inside an object, the shape from [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908)), `get_temp` (a flat three-key payload), and `echo` (no `outputSchema` at all). It is a plain streamable-HTTP server — connect with the **default (legacy)** protocol era.
|
||||||
|
|
||||||
|
Run `list_items` from the Tools tab: the result panel shows the `content[]` text summary ("Found 2 items.") **and** a collapsible **Structured Output** section rendering the schema-validated payload as pretty-printed, copyable JSON. That section is what v2 was dropping — a tool declaring an `outputSchema` returns its real data there, and the text block usually only summarizes it. Run `echo` to confirm the section is absent when a result carries no `structuredContent`.
|
||||||
|
|
||||||
#### Advertised extensions
|
#### Advertised extensions
|
||||||
|
|
||||||
`advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`.
|
`advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||||
|
import { StructuredOutputPanel } from "./StructuredOutputPanel";
|
||||||
|
|
||||||
|
const nested = {
|
||||||
|
items: [
|
||||||
|
{ id: 1, name: "Item A", tags: ["foo", "bar"] },
|
||||||
|
{ id: 2, name: "Item B", tags: ["baz"] },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const flat = {
|
||||||
|
temperature: 25,
|
||||||
|
unit: "C",
|
||||||
|
city: "San Francisco",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Long enough that the payload exceeds the section's max height and scrolls
|
||||||
|
// within it rather than pushing the rest of the result panel down.
|
||||||
|
const large = {
|
||||||
|
rows: Array.from({ length: 60 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
label: `Row ${index + 1}`,
|
||||||
|
value: index * 7,
|
||||||
|
})),
|
||||||
|
total: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta: Meta<typeof StructuredOutputPanel> = {
|
||||||
|
title: "Groups/StructuredOutputPanel",
|
||||||
|
component: StructuredOutputPanel,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof StructuredOutputPanel>;
|
||||||
|
|
||||||
|
export const Nested: Story = {
|
||||||
|
args: {
|
||||||
|
structuredContent: nested,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
expect(
|
||||||
|
canvas.getByRole("heading", { name: "Structured Output" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// The section starts expanded, so the payload is visible without a click.
|
||||||
|
// Asserted on the container's text: once the Prism grammar loads, the JSON
|
||||||
|
// is split across per-token elements, so no single node holds the value.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(canvasElement.textContent).toContain('"Item A"'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Flat: Story = {
|
||||||
|
args: {
|
||||||
|
structuredContent: flat,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Large: Story = {
|
||||||
|
args: {
|
||||||
|
structuredContent: large,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collapsing hides the payload; expanding brings it back.
|
||||||
|
export const Collapsed: Story = {
|
||||||
|
args: {
|
||||||
|
structuredContent: nested,
|
||||||
|
defaultExpanded: false,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const toggle = canvas.getByRole("button", {
|
||||||
|
name: "Expand structured output",
|
||||||
|
});
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
|
await userEvent.click(toggle);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
canvas.getByRole("button", { name: "Collapse structured output" }),
|
||||||
|
).toHaveAttribute("aria-expanded", "true"),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(canvasElement.textContent).toContain('"Item A"'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
|
import { StructuredOutputPanel } from "./StructuredOutputPanel";
|
||||||
|
|
||||||
|
// The real CodeHighlight dynamic-imports the Prism runtime and each grammar
|
||||||
|
// (its own behavior is covered in CodeHighlight.test.tsx). Stub it so these
|
||||||
|
// tests assert on the JSON handed to it, synchronously.
|
||||||
|
vi.mock("../../elements/CodeHighlight/CodeHighlight", () => ({
|
||||||
|
CodeHighlight: ({ language, code }: { language: string; code: string }) => (
|
||||||
|
<pre data-testid="code-highlight" data-language={language}>
|
||||||
|
{code}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const structuredContent = {
|
||||||
|
items: [
|
||||||
|
{ id: 1, name: "Item A", tags: ["foo", "bar"] },
|
||||||
|
{ id: 2, name: "Item B", tags: ["baz"] },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("StructuredOutputPanel", () => {
|
||||||
|
it("renders a labeled section with the payload as pretty-printed JSON", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<StructuredOutputPanel structuredContent={structuredContent} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Structured Output" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const code = screen.getByTestId("code-highlight");
|
||||||
|
expect(code).toHaveAttribute("data-language", "json");
|
||||||
|
expect(code).toHaveTextContent(/"total": 2/);
|
||||||
|
// Nested values are inspectable field by field, not summarized away.
|
||||||
|
expect(code).toHaveTextContent(/"name": "Item A"/);
|
||||||
|
expect(code).toHaveTextContent(/"tags"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts expanded by default", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<StructuredOutputPanel structuredContent={structuredContent} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Collapse structured output" }),
|
||||||
|
).toHaveAttribute("aria-expanded", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts collapsed when defaultExpanded is false", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<StructuredOutputPanel
|
||||||
|
structuredContent={structuredContent}
|
||||||
|
defaultExpanded={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Expand structured output" }),
|
||||||
|
).toHaveAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles between expanded and collapsed", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(
|
||||||
|
<StructuredOutputPanel structuredContent={structuredContent} />,
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Collapse structured output" }),
|
||||||
|
);
|
||||||
|
const toggle = screen.getByRole("button", {
|
||||||
|
name: "Expand structured output",
|
||||||
|
});
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
|
await user.click(toggle);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Collapse structured output" }),
|
||||||
|
).toHaveAttribute("aria-expanded", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty structured payload rather than nothing", () => {
|
||||||
|
renderWithMantine(<StructuredOutputPanel structuredContent={{}} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Structured Output" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("code-highlight")).toHaveTextContent("{}");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Collapse,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import type { CallToolResult } from "@modelcontextprotocol/client";
|
||||||
|
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||||
|
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
|
||||||
|
|
||||||
|
export interface StructuredOutputPanelProps {
|
||||||
|
/** The result's `structuredContent` — the tool's schema-validated payload. */
|
||||||
|
structuredContent: NonNullable<CallToolResult["structuredContent"]>;
|
||||||
|
/** Whether the section starts expanded. Defaults to `true`. */
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bordered box matching the "Resource Links" group in the result panel, so the
|
||||||
|
// two supplementary sections of a tool result read as siblings.
|
||||||
|
const StructuredBox = Paper.withProps({
|
||||||
|
withBorder: true,
|
||||||
|
radius: "md",
|
||||||
|
p: "md",
|
||||||
|
variant: "panel",
|
||||||
|
});
|
||||||
|
|
||||||
|
const StructuredInner = Stack.withProps({
|
||||||
|
gap: "sm",
|
||||||
|
});
|
||||||
|
|
||||||
|
const HeaderRow = Group.withProps({
|
||||||
|
justify: "space-between",
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
// h4 (size h5) for the same reason as the "Resource Links" heading: the panel's
|
||||||
|
// "Results" title is h3, so a sub-box heading is h4 and the heading order never
|
||||||
|
// skips a level (axe `heading-order`).
|
||||||
|
const StructuredHeader = Title.withProps({
|
||||||
|
order: 4,
|
||||||
|
size: "h5",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Caps the payload so a large structured result scrolls within the box instead
|
||||||
|
// of pushing the content blocks out of view. `Autosize` sizes to the content up
|
||||||
|
// to `mah`, so a small object still takes only what it needs.
|
||||||
|
const StructuredScroll = ScrollArea.Autosize.withProps({
|
||||||
|
mah: 400,
|
||||||
|
type: "auto",
|
||||||
|
scrollbars: "y",
|
||||||
|
offsetScrollbars: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsible "Structured Output" section for a tool result's
|
||||||
|
* `structuredContent` (#1908). A tool declaring an `outputSchema` returns its
|
||||||
|
* real payload here — the `content[]` blocks usually only summarize it — so v1
|
||||||
|
* rendered it as its own inspectable JSON section. Without this, the payload is
|
||||||
|
* dropped from the Tools screen entirely, with no hint it was ever returned.
|
||||||
|
*
|
||||||
|
* The JSON is pretty-printed and syntax-highlighted through {@link ContentViewer}
|
||||||
|
* (an `application/json` text block), so it is copyable and scannable field by
|
||||||
|
* field.
|
||||||
|
*/
|
||||||
|
export function StructuredOutputPanel({
|
||||||
|
structuredContent,
|
||||||
|
defaultExpanded = true,
|
||||||
|
}: StructuredOutputPanelProps) {
|
||||||
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StructuredBox>
|
||||||
|
<StructuredInner>
|
||||||
|
<HeaderRow>
|
||||||
|
<StructuredHeader>Structured Output</StructuredHeader>
|
||||||
|
<ExpandToggle
|
||||||
|
expanded={expanded}
|
||||||
|
onToggle={() => setExpanded((value) => !value)}
|
||||||
|
ariaLabel={`${expanded ? "Collapse" : "Expand"} structured output`}
|
||||||
|
/>
|
||||||
|
</HeaderRow>
|
||||||
|
{/* Content stays mounted across a collapse (Mantine `Collapse`), so the
|
||||||
|
highlighted JSON isn't re-rendered from scratch on every toggle. */}
|
||||||
|
<Collapse in={expanded}>
|
||||||
|
<StructuredScroll>
|
||||||
|
<ContentViewer
|
||||||
|
block={{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(structuredContent, null, 2),
|
||||||
|
}}
|
||||||
|
mimeType="application/json"
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
|
</StructuredScroll>
|
||||||
|
</Collapse>
|
||||||
|
</StructuredInner>
|
||||||
|
</StructuredBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -173,6 +173,24 @@ export const ResourceLinksWithLongText: Story = {
|
||||||
decorators: fillHeightDecorators,
|
decorators: fillHeightDecorators,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A tool declaring an `outputSchema` returns its real payload in
|
||||||
|
// `structuredContent`, which the text block only summarizes (#1908). It gets
|
||||||
|
// its own collapsible "Structured Output" section below the content blocks.
|
||||||
|
export const StructuredOutput: Story = {
|
||||||
|
args: {
|
||||||
|
result: {
|
||||||
|
content: [{ type: "text", text: "Found 2 items." }],
|
||||||
|
structuredContent: {
|
||||||
|
items: [
|
||||||
|
{ id: 1, name: "Item A", tags: ["foo", "bar"] },
|
||||||
|
{ id: 2, name: "Item B", tags: ["baz"] },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const ErrorResult: Story = {
|
export const ErrorResult: Story = {
|
||||||
args: {
|
args: {
|
||||||
result: {
|
result: {
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,78 @@ describe("ToolResultPanel", () => {
|
||||||
expect(screen.getByText("divider")).toBeInTheDocument();
|
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 () => {
|
it("invokes onClear when the close button is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onClear = vi.fn();
|
const onClear = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
} from "@modelcontextprotocol/client";
|
} from "@modelcontextprotocol/client";
|
||||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||||
import { ResourceLink } from "../ResourceLink/ResourceLink";
|
import { ResourceLink } from "../ResourceLink/ResourceLink";
|
||||||
|
import { StructuredOutputPanel } from "../StructuredOutputPanel/StructuredOutputPanel";
|
||||||
import { resultHasResourceLinks } from "./toolResultUtils";
|
import { resultHasResourceLinks } from "./toolResultUtils";
|
||||||
|
|
||||||
export interface ToolResultPanelProps {
|
export interface ToolResultPanelProps {
|
||||||
|
|
@ -217,6 +218,16 @@ export function ToolResultPanel({
|
||||||
// available height (and scrolls inside). Plain text/image results keep the
|
// available height (and scrolls inside). Plain text/image results keep the
|
||||||
// scroll-within-card body so a short result doesn't reserve empty height.
|
// scroll-within-card body so a short result doesn't reserve empty height.
|
||||||
const hasLinks = resultHasResourceLinks(result);
|
const hasLinks = resultHasResourceLinks(result);
|
||||||
|
// A tool with an `outputSchema` returns its real payload in
|
||||||
|
// `structuredContent`, which the `content[]` blocks typically only summarize
|
||||||
|
// (#1908). Render it as its own section — including alongside an error or an
|
||||||
|
// empty `content` array, so it is never silently dropped.
|
||||||
|
const structuredNode = result.structuredContent ? (
|
||||||
|
<StructuredOutputPanel
|
||||||
|
key="structured-output"
|
||||||
|
structuredContent={result.structuredContent}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const segmentNodes = segments.map((segment) => {
|
const segmentNodes = segments.map((segment) => {
|
||||||
if (segment.kind === "links") {
|
if (segment.kind === "links") {
|
||||||
|
|
@ -254,22 +265,31 @@ export function ToolResultPanel({
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
{result.isError ? (
|
{result.isError ? (
|
||||||
<ResultScroll>
|
<ResultScroll>
|
||||||
<ErrorAlert>
|
<ResultStack>
|
||||||
{result.content
|
<ErrorAlert>
|
||||||
.filter((b) => b.type === "text")
|
{result.content
|
||||||
.map((b) => b.text)
|
.filter((b) => b.type === "text")
|
||||||
.join("\n")}
|
.map((b) => b.text)
|
||||||
</ErrorAlert>
|
.join("\n")}
|
||||||
|
</ErrorAlert>
|
||||||
|
{structuredNode}
|
||||||
|
</ResultStack>
|
||||||
</ResultScroll>
|
</ResultScroll>
|
||||||
) : result.content.length === 0 ? (
|
) : result.content.length === 0 && !structuredNode ? (
|
||||||
<ResultScroll>
|
<ResultScroll>
|
||||||
<Text c="dimmed">No results yet</Text>
|
<Text c="dimmed">No results yet</Text>
|
||||||
</ResultScroll>
|
</ResultScroll>
|
||||||
) : hasLinks ? (
|
) : hasLinks ? (
|
||||||
<FillStack>{segmentNodes}</FillStack>
|
<FillStack>
|
||||||
|
{segmentNodes}
|
||||||
|
{structuredNode}
|
||||||
|
</FillStack>
|
||||||
) : (
|
) : (
|
||||||
<ResultScroll>
|
<ResultScroll>
|
||||||
<ResultStack>{segmentNodes}</ResultStack>
|
<ResultStack>
|
||||||
|
{segmentNodes}
|
||||||
|
{structuredNode}
|
||||||
|
</ResultStack>
|
||||||
</ResultScroll>
|
</ResultScroll>
|
||||||
)}
|
)}
|
||||||
</PanelStack>
|
</PanelStack>
|
||||||
|
|
|
||||||
15
test-servers/configs/structured-output-http.json
Normal file
15
test-servers/configs/structured-output-http.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "structured-output-showcase",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"tools": [
|
||||||
|
{ "preset": "list_items" },
|
||||||
|
{ "preset": "get_temp" },
|
||||||
|
{ "preset": "echo" }
|
||||||
|
],
|
||||||
|
"transport": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"port": 6601
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
createGetAnnotatedMessageTool,
|
createGetAnnotatedMessageTool,
|
||||||
createGetTempTool,
|
createGetTempTool,
|
||||||
createGetTempExtraTool,
|
createGetTempExtraTool,
|
||||||
|
createListItemsTool,
|
||||||
createAddResourceTool,
|
createAddResourceTool,
|
||||||
createRemoveResourceTool,
|
createRemoveResourceTool,
|
||||||
createAddToolTool,
|
createAddToolTool,
|
||||||
|
|
@ -158,6 +159,8 @@ function resolveToolPreset(
|
||||||
return createGetTempTool();
|
return createGetTempTool();
|
||||||
case "get_temp_extra":
|
case "get_temp_extra":
|
||||||
return createGetTempExtraTool();
|
return createGetTempExtraTool();
|
||||||
|
case "list_items":
|
||||||
|
return createListItemsTool();
|
||||||
case "add_resource":
|
case "add_resource":
|
||||||
return createAddResourceTool();
|
return createAddResourceTool();
|
||||||
case "remove_resource":
|
case "remove_resource":
|
||||||
|
|
|
||||||
|
|
@ -1048,6 +1048,48 @@ export function createGetTempTool(): ToolDefinition {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Output schema for list_items: a nested list of items plus a total. */
|
||||||
|
const ListItemsOutputSchema = z.object({
|
||||||
|
items: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.number().describe("Item id"),
|
||||||
|
name: z.string().describe("Item name"),
|
||||||
|
tags: z.array(z.string()).describe("Item tags"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.describe("The items"),
|
||||||
|
total: z.number().describe("Number of items"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a "list_items" tool returning a short `content` summary alongside a
|
||||||
|
* DEEPLY NESTED `structuredContent` (objects inside arrays inside an object) —
|
||||||
|
* the shape from #1908, where the structured payload carries everything the
|
||||||
|
* text block only summarizes. Exercises the Structured Output section of the
|
||||||
|
* Tools screen result panel, which `get_temp`'s flat three-key object does not.
|
||||||
|
*/
|
||||||
|
export function createListItemsTool(): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: "list_items",
|
||||||
|
description: "Returns a list of items with nested structured output",
|
||||||
|
inputSchema: {},
|
||||||
|
outputSchema: ListItemsOutputSchema,
|
||||||
|
handler: async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 1, name: "Item A", tags: ["foo", "bar"] },
|
||||||
|
{ id: 2, name: "Item B", tags: ["baz"] },
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: `Found ${items.length} items.` },
|
||||||
|
],
|
||||||
|
structuredContent: { items, total: items.length },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a "get_temp_extra" tool that declares the same output schema as
|
* Create a "get_temp_extra" tool that declares the same output schema as
|
||||||
* get_temp but returns an EXTRA, undeclared property in structuredContent.
|
* get_temp but returns an EXTRA, undeclared property in structuredContent.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue