Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
| `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) |
| `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) |
| `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) |
Expand Down Expand Up @@ -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.

#### 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-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`.
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
},
};
Comment thread
cliffhall marked this conversation as resolved.

// 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"'),
);
},
};
Original file line number Diff line number Diff line change
@@ -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("{}");
});
});
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,24 @@ export const ResourceLinksWithLongText: Story = {
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 = {
args: {
result: {
Expand Down
Loading
Loading