diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx
index 1b04403fb..8c9ee3331 100644
--- a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx
+++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx
@@ -122,14 +122,16 @@ describe("ContentViewer", () => {
type: "resource_link",
uri: "ui://app",
name: "Cool App",
- description: "An app",
mimeType: "text/html",
};
renderWithMantine();
expect(screen.getByText("Cool App")).toBeInTheDocument();
- expect(screen.getByText("An app")).toBeInTheDocument();
expect(screen.getByText("text/html")).toBeInTheDocument();
- expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ // The URI copy button is present, but there's no expand control.
+ expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /^Expand resource/ }),
+ ).not.toBeInTheDocument();
});
it("renders nothing for unknown block types", () => {
diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx
index 64087d9a1..fe46a4894 100644
--- a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx
+++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx
@@ -367,7 +367,6 @@ function BlockContent({
);
diff --git a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.test.tsx b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.test.tsx
index 01e6c2bc4..c52614d62 100644
--- a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.test.tsx
+++ b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.test.tsx
@@ -23,4 +23,30 @@ describe("ExpandToggle", () => {
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(onToggle).toHaveBeenCalledTimes(1);
});
+
+ it("uses ariaLabel as the accessible name when provided", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.getByRole("button", { name: "Expand resource demo://r/1" }),
+ ).toBeInTheDocument();
+ });
+
+ it("exposes aria-expanded=false when collapsed", () => {
+ renderWithMantine();
+ expect(screen.getByRole("button")).toHaveAttribute(
+ "aria-expanded",
+ "false",
+ );
+ });
+
+ it("exposes aria-expanded=true when expanded", () => {
+ renderWithMantine();
+ expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
+ });
});
diff --git a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx
index d8965012e..0d578281d 100644
--- a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx
+++ b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx
@@ -5,16 +5,28 @@ export interface ExpandToggleProps {
/** Whether the owning entry is currently expanded. */
expanded: boolean;
onToggle: () => void;
+ /**
+ * Overrides the accessible name (aria-label). Defaults to the tooltip text
+ * ("Expand"/"Collapse"). Pass a per-entry name (e.g. including the resource
+ * URI) when several toggles sit in one list, so assistive tech can tell them
+ * apart; the visible tooltip stays the plain verb.
+ */
+ ariaLabel?: string;
}
/**
* Icon toggle for a per-entry expand/collapse control (Protocol, Network, and
* Task cards). Uses the same expand/collapse-vertical icons as the list-level
* ListToggle: collapsed shows the expand icon, expanded shows the collapse
- * icon. The aria-label stays "Expand"/"Collapse" so it reads the same as the
- * text button it replaced.
+ * icon. The tooltip stays "Expand"/"Collapse" (the same verb as the text button
+ * it replaced); `aria-expanded` exposes the disclosure state and `ariaLabel`
+ * can distinguish sibling toggles.
*/
-export function ExpandToggle({ expanded, onToggle }: ExpandToggleProps) {
+export function ExpandToggle({
+ expanded,
+ onToggle,
+ ariaLabel,
+}: ExpandToggleProps) {
const Icon = expanded ? RiCollapseVerticalLine : RiExpandVerticalLine;
const label = expanded ? "Collapse" : "Expand";
return (
@@ -23,7 +35,8 @@ export function ExpandToggle({ expanded, onToggle }: ExpandToggleProps) {
variant="subtle"
color="gray"
size="md"
- aria-label={label}
+ aria-label={ariaLabel ?? label}
+ aria-expanded={expanded}
onClick={onToggle}
>
diff --git a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.stories.tsx b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.stories.tsx
index cdbecd362..2b255c26f 100644
--- a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.stories.tsx
+++ b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.stories.tsx
@@ -13,7 +13,6 @@ export const Full: Story = {
args: {
uri: "file:///docs/readme.md",
name: "Readme",
- description: "Project documentation",
mimeType: "text/markdown",
},
};
diff --git a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.test.tsx b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.test.tsx
index 1a3dedc6b..526b3727c 100644
--- a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.test.tsx
+++ b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.test.tsx
@@ -5,18 +5,12 @@ import { ResourceLinkInfo } from "./ResourceLinkInfo";
const URI = "file:///docs/readme.md";
describe("ResourceLinkInfo", () => {
- it("renders uri, name, description, and mimeType", () => {
+ it("renders uri, name, and mimeType", () => {
renderWithMantine(
- ,
+ ,
);
expect(screen.getByText(URI)).toBeInTheDocument();
expect(screen.getByText("Readme")).toBeInTheDocument();
- expect(screen.getByText("The project readme")).toBeInTheDocument();
expect(screen.getByText("text/markdown")).toBeInTheDocument();
});
diff --git a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx
index 71e2b5e17..a9cc1e4b6 100644
--- a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx
+++ b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx
@@ -1,18 +1,19 @@
import type { ReactNode } from "react";
import { Badge, Group, Stack, Text } from "@mantine/core";
+import { CopyButton } from "../CopyButton/CopyButton";
export interface ResourceLinkInfoProps {
- /** The linked resource's URI (always shown). */
+ /** The linked resource's URI (always shown, with a copy button). */
uri: string;
- /** Optional human-friendly name shown beneath the URI. */
+ /** Optional human-friendly name shown above the URI. */
name?: string;
- /** Optional description shown beneath the name. */
- description?: string;
/** Optional MIME type shown as a badge. */
mimeType?: string;
/**
- * Optional trailing element placed at the end of the URI row — e.g. an
- * expand/collapse indicator supplied by an interactive wrapper.
+ * Optional trailing element placed at the end of the URI row (opposite the
+ * copy button) — e.g. an expand/collapse control supplied by an interactive
+ * wrapper. Mirrors ProtocolEntry, whose toggle sits on the row below the
+ * badges.
*/
action?: ReactNode;
}
@@ -21,70 +22,97 @@ const HeaderStack = Stack.withProps({
gap: 4,
});
+// Name (left) + MIME badge (right). `justify` is set per-instance: spread when
+// a name is present, otherwise the badge hugs the right.
+const HeaderRow = Group.withProps({
+ wrap: "nowrap",
+ gap: "xs",
+ align: "center",
+});
+
+// Copy control + URI (left) and the optional expand/collapse control (right),
+// on the line below the header — mirroring ProtocolEntry's controls row.
const UriRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
gap: "xs",
- align: "flex-start",
+ align: "center",
});
+// Copy button + URI cluster; flexes so the URI fills and the action stays right.
+const UriCluster = Group.withProps({
+ wrap: "nowrap",
+ gap: "xs",
+ align: "center",
+ flex: 1,
+ miw: 0,
+});
+
+// Match how ProtocolEntry/NetworkEntry render a URL: `sm` / `fw: 500`, in the
+// default sans-serif face and text color (not a blue monospace "link"). The
+// `monoBreak` variant only adds `word-break: break-all` so a long URI wraps
+// within the card instead of overflowing.
const UriText = Text.withProps({
size: "sm",
- c: "blue",
- ff: "monospace",
+ fw: 500,
variant: "monoBreak",
flex: 1,
miw: 0,
});
-const MetaGroup = Group.withProps({
- gap: "xs",
- wrap: "nowrap",
-});
-
const MimeBadge = Badge.withProps({
- size: "sm",
- variant: "light",
- color: "blue",
+ // Match the point size of the ProtocolEntry method/status badges; the
+ // lowercase MIME text reads smaller than their uppercase labels at `sm`.
+ size: "md",
+ radius: "sm",
// MIME types are conventionally lowercase; keep them as-is rather than
// letting Badge's default uppercase transform mangle them.
tt: "none",
+ autoContrast: false,
+ // Light mode: the tinted blue-light chip (unchanged). Dark mode: a solid
+ // dark-blue fill with white text — matching the solid ProtocolEntry badges
+ // rather than a washed-out translucent tint.
+ bg: "light-dark(var(--mantine-color-blue-light), var(--mantine-color-blue-9))",
+ c: "light-dark(var(--mantine-color-blue-light-color), var(--mantine-color-white))",
});
const NameText = Text.withProps({
size: "sm",
fw: 600,
-});
-
-const DescriptionText = Text.withProps({
- size: "sm",
- c: "dimmed",
+ flex: 1,
+ miw: 0,
});
/**
- * Pure-display metadata for a `resource_link`: the URI (monospace, link-styled),
- * optional name / description, and a MIME-type badge. The optional `action`
- * slot lets an interactive wrapper (e.g. {@link ResourceLink}) place an
- * expand/collapse indicator in the URI row.
+ * Pure-display metadata for a `resource_link`: an optional name and MIME-type
+ * badge on the header row, then the URI on the line below with a copy button.
+ * The URI is styled like ProtocolEntry/NetworkEntry URLs (`sm` / `fw: 500`,
+ * default sans-serif face and color), not a blue monospace link. The optional
+ * `action` slot lets an interactive wrapper (e.g. {@link ResourceLink}) place
+ * an expand/collapse control at the end of the URI row.
*/
export function ResourceLinkInfo({
uri,
name,
- description,
mimeType,
action,
}: ResourceLinkInfoProps) {
+ const hasHeader = Boolean(name || mimeType);
return (
-
- {uri}
-
+ {hasHeader && (
+
+ {name && {name}}
{mimeType && {mimeType}}
- {action}
-
+
+ )}
+
+
+
+ {uri}
+
+ {action}
- {name && {name}}
- {description && {description}}
);
}
diff --git a/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx b/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx
index 5c4dc89b5..a1dbac366 100644
--- a/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx
+++ b/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx
@@ -41,13 +41,16 @@ export const Static: Story = {
args: {
uri: URI,
name: "Readme",
- description: "Project documentation",
mimeType: "text/markdown",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(URI)).toBeInTheDocument();
- expect(canvas.queryByRole("button")).not.toBeInTheDocument();
+ // Copy button is present; there's no expand control in the static card.
+ expect(canvas.getByRole("button", { name: "Copy" })).toBeInTheDocument();
+ expect(
+ canvas.queryByRole("button", { name: /^Expand resource/ }),
+ ).not.toBeInTheDocument();
},
};
@@ -55,18 +58,16 @@ export const Expandable: Story = {
args: {
uri: URI,
name: "Readme",
- description: "Click to read on demand",
mimeType: "text/markdown",
onReadResource: readMarkdown,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const button = canvas.getByRole("button", {
- name: `Expand resource ${URI}`,
- });
- await userEvent.click(button);
+ await userEvent.click(
+ canvas.getByRole("button", { name: `Expand resource ${URI}` }),
+ );
await waitFor(() =>
- expect(canvas.getByText("Resource:")).toBeInTheDocument(),
+ expect(canvas.getByText(/Read on demand/)).toBeInTheDocument(),
);
},
};
@@ -77,7 +78,6 @@ export const LargeResult: Story = {
args: {
uri: BLOB_URI,
name: "Blob Resource",
- description: "A large gzipped resource",
mimeType: "application/gzip",
onReadResource: readLargeBlob,
},
@@ -86,8 +86,10 @@ export const LargeResult: Story = {
await userEvent.click(
canvas.getByRole("button", { name: `Expand resource ${BLOB_URI}` }),
);
+ // The `"blob"` key appears only in the expanded read result's JSON (not in
+ // the metadata badge), so it confirms the inline result rendered.
await waitFor(() =>
- expect(canvas.getByText("Resource:")).toBeInTheDocument(),
+ expect(canvas.getByText(/"blob":/)).toBeInTheDocument(),
);
},
};
diff --git a/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx b/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx
index f78e84c84..50a4f13b8 100644
--- a/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx
+++ b/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx
@@ -15,24 +15,22 @@ const readResult = (text: string): ReadResourceResult => ({
});
describe("ResourceLink", () => {
- it("renders uri, name, description, and mimeType", () => {
+ it("renders uri, name, and mimeType", () => {
renderWithMantine(
- ,
+ ,
);
expect(screen.getByText(URI)).toBeInTheDocument();
expect(screen.getByText("Readme")).toBeInTheDocument();
- expect(screen.getByText("The project readme")).toBeInTheDocument();
expect(screen.getByText("text/markdown")).toBeInTheDocument();
});
- it("is not interactive without onReadResource", () => {
+ it("renders no expand control without onReadResource", () => {
renderWithMantine();
- expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ // The URI copy button is always present, but there's no expand affordance.
+ expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: `Expand resource ${URI}` }),
+ ).not.toBeInTheDocument();
});
it("reads the resource on demand and renders the result inline", async () => {
@@ -42,22 +40,19 @@ describe("ResourceLink", () => {
,
);
- const button = screen.getByRole("button", {
- name: `Expand resource ${URI}`,
- });
- expect(button).toHaveAttribute("aria-expanded", "false");
-
- await user.click(button);
+ await user.click(
+ screen.getByRole("button", { name: `Expand resource ${URI}` }),
+ );
expect(onReadResource).toHaveBeenCalledWith(URI);
+ // The full read result is rendered inline as formatted JSON.
await waitFor(() =>
- expect(screen.getByText("Resource:")).toBeInTheDocument(),
+ expect(screen.getByText(/"hello body"/)).toBeInTheDocument(),
);
- // The full read result is rendered as formatted JSON.
- expect(screen.getByText(/"hello body"/)).toBeInTheDocument();
+ // The toggle flips to the collapse control once expanded.
expect(
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
- ).toHaveAttribute("aria-expanded", "true");
+ ).toBeInTheDocument();
});
it("collapses and re-expands without re-reading (result cached)", async () => {
@@ -74,13 +69,16 @@ describe("ResourceLink", () => {
expect(screen.getByText(/"cached body"/)).toBeInTheDocument(),
);
- // Collapse — content is hidden.
+ // Collapse — the toggle flips back to the expand control. (The read result
+ // stays mounted inside the animated Collapse, so it isn't re-read.)
await user.click(
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
);
- expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: `Expand resource ${URI}` }),
+ ).toBeInTheDocument();
- // Re-expand — content returns without a second read.
+ // Re-expand — content is still present without a second read.
await user.click(
screen.getByRole("button", { name: `Expand resource ${URI}` }),
);
@@ -95,7 +93,9 @@ describe("ResourceLink", () => {
,
);
- await user.click(screen.getByRole("button"));
+ await user.click(
+ screen.getByRole("button", { name: `Expand resource ${URI}` }),
+ );
await waitFor(() =>
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
);
@@ -111,7 +111,9 @@ describe("ResourceLink", () => {
,
);
- await user.click(screen.getByRole("button"));
+ await user.click(
+ screen.getByRole("button", { name: `Expand resource ${URI}` }),
+ );
await waitFor(() =>
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
);
diff --git a/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx b/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx
index 2e474783a..1430c46e9 100644
--- a/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx
+++ b/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx
@@ -1,25 +1,15 @@
import { useState } from "react";
-import {
- Alert,
- Loader,
- Paper,
- ScrollArea,
- Stack,
- Text,
- UnstyledButton,
-} from "@mantine/core";
+import { Alert, Card, Collapse, ScrollArea, Stack, Text } from "@mantine/core";
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
-import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
+import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
import { ResourceLinkInfo } from "../../elements/ResourceLinkInfo/ResourceLinkInfo";
export interface ResourceLinkProps {
/** The linked resource's URI (always shown). */
uri: string;
- /** Optional human-friendly name shown beneath the URI. */
+ /** Optional human-friendly name shown above the URI. */
name?: string;
- /** Optional description shown beneath the name. */
- description?: string;
/** Optional MIME type shown as a badge. */
mimeType?: string;
/**
@@ -30,15 +20,15 @@ export interface ResourceLinkProps {
onReadResource?: (uri: string) => Promise;
}
-const LinkCard = Paper.withProps({
+// Recessed "inset" surface so each link card reads the same as a Protocol
+// message card (ProtocolEntry), matching its colors in both light and dark
+// modes; the inset variant also raises nested Code blocks (the expanded read
+// result) onto a lighter surface via its cascade variable.
+const LinkCard = Card.withProps({
withBorder: true,
- p: "sm",
+ padding: "sm",
radius: "md",
-});
-
-const HeaderButton = UnstyledButton.withProps({
- w: "100%",
- ta: "left",
+ variant: "inset",
});
const ExpandedSection = Stack.withProps({
@@ -58,12 +48,6 @@ const ResultScroll = ScrollArea.Autosize.withProps({
offsetScrollbars: true,
});
-const ResourceLabel = Text.withProps({
- size: "sm",
- fw: 600,
- c: "green",
-});
-
const LoadingText = Text.withProps({
size: "sm",
c: "dimmed",
@@ -79,7 +63,6 @@ const LoadingText = Text.withProps({
export function ResourceLink({
uri,
name,
- description,
mimeType,
onReadResource,
}: ResourceLinkProps) {
@@ -113,49 +96,43 @@ export function ResourceLink({
}
}
- const Chevron = expanded ? RiArrowDownSLine : RiArrowRightSLine;
+ // Same tooltip'd expand/collapse control as ProtocolEntry (ExpandToggle),
+ // placed in the header row's meta slot as a sibling of the URI's copy button.
+ // A per-resource `ariaLabel` keeps the toggles distinguishable to assistive
+ // tech when several links are listed (the visible tooltip stays "Expand").
const action = expandable ? (
- loading ? (
-
- ) : (
-
- )
- ) : undefined;
-
- const info = (
- void toggle()}
+ ariaLabel={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
/>
- );
+ ) : undefined;
return (
- {expandable ? (
- void toggle()}
- aria-expanded={expanded}
- aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
- >
- {info}
-
- ) : (
- info
- )}
- {expanded && (
-
- {loading ? (
- Loading resource…
- ) : error !== null ? (
-
- {error}
-
- ) : result !== null ? (
- <>
- Resource:
+
+ {/* Same expand/collapse animation as ProtocolEntry: content stays mounted
+ (so the cached read result survives a collapse) and animates via
+ Mantine's Collapse. */}
+ {expandable && (
+
+
+ {loading ? (
+ Loading resource…
+ ) : error !== null ? (
+
+ {error}
+
+ ) : result !== null ? (
- >
- ) : null}
-
+ ) : null}
+
+
)}
);
diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx
index 059b39949..49eb35b4d 100644
--- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx
+++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx
@@ -1,5 +1,6 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
+import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import { Card, Flex } from "@mantine/core";
import { fn } from "storybook/test";
import { ToolResultPanel } from "./ToolResultPanel";
@@ -60,6 +61,61 @@ const mixedResult: CallToolResult = {
],
};
+const resourceLinksResult: CallToolResult = {
+ content: [
+ {
+ type: "text",
+ text: "Here are 3 resource links to resources available in this server:",
+ },
+ {
+ type: "resource_link",
+ uri: "demo://resource/dynamic/blob/1",
+ name: "Blob Resource 1",
+ mimeType: "text/plain",
+ },
+ {
+ type: "resource_link",
+ uri: "demo://resource/dynamic/text/2",
+ name: "Text Resource 2",
+ mimeType: "text/plain",
+ },
+ {
+ type: "resource_link",
+ uri: "demo://resource/dynamic/blob/3",
+ name: "Blob Resource 3",
+ mimeType: "text/plain",
+ },
+ ],
+};
+
+// A long text block preceding the links, so the 50%-height cap on the text is
+// exercised: the text scrolls within (at most) half the card and the Resource
+// Links box keeps the rest.
+const resourceLinksWithLongTextResult: CallToolResult = {
+ content: [
+ {
+ type: "text",
+ text: "Here are 3 resource links to resources available in this server. "
+ .repeat(60)
+ .trim(),
+ },
+ ...resourceLinksResult.content.filter((b) => b.type === "resource_link"),
+ ],
+};
+
+// Mirrors the Tools screen's full-height result card (ContentPane height →
+// ContentCard `flex: 1`) so the box fills the available space and scrolls
+// within, as it does in the app.
+const fillHeightDecorators: Decorator[] = [
+ (Story) => (
+
+
+
+
+
+ ),
+];
+
export const Empty: Story = {
args: {
result: emptyResult,
@@ -90,6 +146,33 @@ export const MixedContent: Story = {
},
};
+// A run of `resource_link` blocks is grouped into one scrollable "Resource
+// Links" box, with each link card in the recessed inset surface that matches
+// the Protocol message cards. The decorator mirrors the Tools screen's
+// full-height result card (ContentPane height → ContentCard `flex: 1`) so the
+// box fills the available space and scrolls within, as it does in the app.
+export const ResourceLinks: Story = {
+ args: {
+ result: resourceLinksResult,
+ onReadResource: async (uri: string) => ({
+ contents: [{ uri, mimeType: "text/plain", text: `Contents of ${uri}` }],
+ }),
+ },
+ decorators: fillHeightDecorators,
+};
+
+// A long text block above the links is capped at half the card height and
+// scrolls within, so it can't push the Resource Links box out of view.
+export const ResourceLinksWithLongText: Story = {
+ args: {
+ result: resourceLinksWithLongTextResult,
+ onReadResource: async (uri: string) => ({
+ contents: [{ uri, mimeType: "text/plain", text: `Contents of ${uri}` }],
+ }),
+ },
+ decorators: fillHeightDecorators,
+};
+
export const ErrorResult: Story = {
args: {
result: {
diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx
index 0874cb1e3..9f608af19 100644
--- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx
+++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx
@@ -7,6 +7,7 @@ import {
waitFor,
} from "../../../test/renderWithMantine";
import { ToolResultPanel } from "./ToolResultPanel";
+import { resultHasResourceLinks } from "./toolResultUtils";
const okResult: CallToolResult = {
content: [{ type: "text", text: "ok" }],
@@ -41,7 +42,7 @@ describe("ToolResultPanel", () => {
expect(screen.getByText("No results yet")).toBeInTheDocument();
});
- it("renders a resource_link block as an expandable ResourceLink", async () => {
+ 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" }],
@@ -60,6 +61,10 @@ describe("ToolResultPanel", () => {
/>,
);
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" }),
);
@@ -69,6 +74,41 @@ describe("ToolResultPanel", () => {
);
});
+ 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( {}} />);
+ // 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( {}} />);
+ // 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();
+ });
+
it("invokes onClear when the close button is clicked", async () => {
const user = userEvent.setup();
const onClear = vi.fn();
@@ -76,4 +116,36 @@ describe("ToolResultPanel", () => {
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);
+ });
+ });
});
diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx
index 885b39fd9..75e8552a1 100644
--- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx
+++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx
@@ -2,6 +2,7 @@ import {
Alert,
CloseButton,
Group,
+ Paper,
ScrollArea,
Stack,
Text,
@@ -13,6 +14,7 @@ import type {
} from "@modelcontextprotocol/sdk/types.js";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { ResourceLink } from "../ResourceLink/ResourceLink";
+import { resultHasResourceLinks } from "./toolResultUtils";
export interface ToolResultPanelProps {
result: CallToolResult;
@@ -29,13 +31,44 @@ export interface ToolResultPanelProps {
onReadResource?: (uri: string) => Promise;
}
-// Outer column: the header pins (`flex: 0 0 auto`) and the scroll region
-// absorbs overflow when the enclosing card hits its `mah`. `mih: 0` lets the
-// flex children shrink below their content's intrinsic height.
+type ContentBlock = CallToolResult["content"][number];
+type ResourceLinkBlock = Extract;
+
+// A result's content is rendered as a run of segments in original order: each
+// non-link block on its own, and every maximal run of consecutive
+// `resource_link` blocks collapsed into one grouped "Resource Links" box.
+type ResultSegment =
+ | { kind: "links"; links: { block: ResourceLinkBlock; index: number }[] }
+ | { kind: "block"; block: ContentBlock; index: number };
+
+// Walk the content array once, coalescing adjacent `resource_link` blocks into a
+// single `links` segment so a run of links renders inside one scrollable box
+// while preserving the overall block order.
+function segmentContent(content: ContentBlock[]): ResultSegment[] {
+ const segments: ResultSegment[] = [];
+ content.forEach((block, index) => {
+ if (block.type === "resource_link") {
+ const last = segments[segments.length - 1];
+ if (last && last.kind === "links") {
+ last.links.push({ block, index });
+ } else {
+ segments.push({ kind: "links", links: [{ block, index }] });
+ }
+ } else {
+ segments.push({ kind: "block", block, index });
+ }
+ });
+ return segments;
+}
+
+// Outer column fills the (full-height) result card: the header pins
+// (`flex: 0 0 auto`) and the body below fills the rest. `mih: 0` lets the flex
+// children shrink below their content's intrinsic height.
const PanelStack = Stack.withProps({
gap: "md",
miw: 0,
mih: 0,
+ flex: 1,
});
const HeaderRow = Group.withProps({
@@ -50,10 +83,10 @@ const HeaderLeft = Group.withProps({
wrap: "nowrap",
});
-// `0 1 auto` + `mih: 0` lets the scroll region shrink (never grow) so a short
-// result doesn't reserve height while a long one scrolls within the card cap.
+// Body scroll region for non-resource-link results: fills the card and scrolls
+// within it when the content is taller than the available space.
const ResultScroll = ScrollArea.withProps({
- flex: "0 1 auto",
+ flex: 1,
miw: 0,
mih: 0,
type: "auto",
@@ -65,11 +98,138 @@ const ResultStack = Stack.withProps({
gap: "md",
});
+// A non-link block that shares the card with a "Resource Links" box: capped at
+// half the available height and scrollable within, so a long text block can't
+// crowd the links box out of view (it keeps the remaining space). `Autosize`
+// sizes to content up to the cap, so a short block still takes only what it
+// needs. Without links, non-link blocks flow in the main scroll body instead.
+const NonLinkCap = ScrollArea.Autosize.withProps({
+ mah: "50%",
+ flex: "0 1 auto",
+ mih: 0,
+ type: "auto",
+ scrollbars: "y",
+ offsetScrollbars: true,
+});
+
+// Body column for results that contain a "Resource Links" box: it fills the
+// card so the box (which is `flex: 1` within it) can grow to the available
+// height and scroll internally, rather than capping at its content height.
+const FillStack = Stack.withProps({
+ gap: "md",
+ flex: 1,
+ mih: 0,
+});
+
+// Grouped container for a run of `resource_link` blocks — a bordered box with a
+// pinned "Resource Links" heading and its own scroll region, mirroring the
+// "Messages" box in the Protocol monitoring sidebar (ProtocolListPanel). The
+// `panel` variant makes it a flex column (overflow hidden, min-height 0) and
+// `flex: 1` lets it grow to fill the result card's available height.
+const ResourceLinksBox = Paper.withProps({
+ withBorder: true,
+ radius: "md",
+ p: "md",
+ variant: "panel",
+ flex: 1,
+ mih: 0,
+});
+
+const ResourceLinksInner = Stack.withProps({
+ gap: "sm",
+ flex: 1,
+ mih: 0,
+});
+
+const ResourceLinksHeader = Title.withProps({
+ // The panel "Results" title is h3 (size h4); this sub-box heading is h4 so the
+ // heading order doesn't skip a level (axe `heading-order`).
+ order: 4,
+ size: "h5",
+});
+
+// Fills the box below the pinned heading and scrolls the link list within it.
+const ResourceLinksScroll = ScrollArea.withProps({
+ flex: 1,
+ mih: 0,
+ type: "auto",
+ scrollbars: "y",
+ offsetScrollbars: true,
+});
+
+const ResourceLinksStack = Stack.withProps({
+ gap: "sm",
+});
+
+function ResourceLinksGroup({
+ links,
+ onReadResource,
+}: {
+ links: { block: ResourceLinkBlock; index: number }[];
+ onReadResource?: (uri: string) => Promise;
+}) {
+ return (
+
+
+ Resource Links
+
+
+ {links.map(({ block, index }) => (
+
+ ))}
+
+
+
+
+ );
+}
+
export function ToolResultPanel({
result,
onClear,
onReadResource,
}: ToolResultPanelProps) {
+ const segments =
+ result.isError || result.content.length === 0
+ ? []
+ : segmentContent(result.content);
+ // Results with a Resource Links box fill the card so the box grows to 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.
+ const hasLinks = resultHasResourceLinks(result);
+
+ const segmentNodes = segments.map((segment) => {
+ if (segment.kind === "links") {
+ return (
+
+ );
+ }
+ const viewer = (
+
+ );
+ // Alongside a Resource Links box, cap the block at half the height (and let
+ // it scroll); on its own it flows in the outer scroll body uncapped.
+ return hasLinks ? (
+ {viewer}
+ ) : (
+ viewer
+ );
+ });
+
return (
@@ -83,39 +243,26 @@ export function ToolResultPanel({
-
-
- {result.isError ? (
-
- {result.content
- .filter((b) => b.type === "text")
- .map((b) => b.text)
- .join("\n")}
-
- ) : result.content.length === 0 ? (
- No results yet
- ) : (
- result.content.map((block, index) =>
- block.type === "resource_link" ? (
-
- ) : (
-
- ),
- )
- )}
-
-
+ {result.isError ? (
+
+
+ {result.content
+ .filter((b) => b.type === "text")
+ .map((b) => b.text)
+ .join("\n")}
+
+
+ ) : result.content.length === 0 ? (
+
+ No results yet
+
+ ) : hasLinks ? (
+ {segmentNodes}
+ ) : (
+
+ {segmentNodes}
+
+ )}
);
}
diff --git a/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts b/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts
new file mode 100644
index 000000000..3098c4da1
--- /dev/null
+++ b/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts
@@ -0,0 +1,14 @@
+import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+
+/**
+ * Whether a result renders a "Resource Links" box — i.e. a non-error result
+ * with at least one `resource_link` block. Hosts use this to decide whether the
+ * result surface should fill the available height (so the box can grow and
+ * scroll internally); plain text/image results keep their content-sized card.
+ */
+export function resultHasResourceLinks(result: CallToolResult): boolean {
+ return (
+ !result.isError &&
+ result.content.some((block) => block.type === "resource_link")
+ );
+}
diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx
index ecd8a622a..15692b6aa 100644
--- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx
+++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx
@@ -163,7 +163,7 @@ describe("ToolsScreen", () => {
expect(screen.queryByText("Results")).toBeNull();
});
- it("renders the result panel when callState has a result", () => {
+ it("renders a content-sized result card for a plain text result", () => {
renderWithMantine(
{
/>,
);
expect(screen.getByText("Results")).toBeInTheDocument();
+ // No links → the result card is NOT flex-filled (sizes to content).
+ const card = screen.getByText("Results").closest(".mantine-Card-root");
+ expect(card).not.toHaveStyle({ flex: "1" });
+ });
+
+ it("fills the result card with a Resource Links box when the result has links", () => {
+ // A resource_link result takes the full-height (`flex={1}`) card branch so
+ // the Resource Links box can grow and scroll within.
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.getByRole("heading", { name: "Resource Links" }),
+ ).toBeInTheDocument();
+ // Links → the result card fills the pane (`flex: 1`), distinguishing this
+ // branch from the content-sized text-result card above.
+ const card = screen.getByText("Results").closest(".mantine-Card-root");
+ expect(card).toHaveStyle({ flex: "1" });
});
it("invokes onCallTool with form values on Execute", async () => {
diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
index aa312e18a..d538f556c 100644
--- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
+++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
@@ -10,6 +10,7 @@ import {
type ToolProgress,
} from "../../groups/ToolDetailPanel/ToolDetailPanel";
import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel";
+import { resultHasResourceLinks } from "../../groups/ToolResultPanel/toolResultUtils";
import { collectSchemaDefaults } from "../../../utils/jsonUtils";
export interface ToolCallState {
@@ -181,7 +182,14 @@ export function ToolsScreen({
// `result` (App.tsx), so the executing form (progress + cancel) shows
// until the result lands.
-
+ {/* Fill the pane's full height only when the result renders a
+ "Resource Links" box, so that box can expand into the available
+ space and scroll within. Plain text/image/error results keep the
+ content-sized card (matching the input-form state) instead of
+ reserving a tall empty card. */}
+
onClearResult?.()}