diff --git a/.gitignore b/.gitignore
index ced7cf11..44b45a7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
.claude/
node_modules
dist
+clients/*/build
test-servers/build
.env
/.playwright-mcp/
diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx
index 2c283212..78db6474 100644
--- a/clients/web/src/App.tsx
+++ b/clients/web/src/App.tsx
@@ -1604,6 +1604,19 @@ function App() {
[inspectorClient],
);
+ // Read-on-demand handler for `resource_link` blocks in a tool result. Unlike
+ // `onReadResource` (which drives the Resources screen's preview panel via
+ // shared state), this returns the contents directly so each ResourceLink can
+ // own and inline its own fetched content.
+ const onReadResourceContents = useCallback(
+ async (uri: string) => {
+ if (!inspectorClient) throw new Error("Client is not connected");
+ const invocation = await inspectorClient.readResource(uri);
+ return invocation.result;
+ },
+ [inspectorClient],
+ );
+
const onSubscribeResource = useCallback(
(uri: string) => {
if (!inspectorClient) return;
@@ -2172,6 +2185,7 @@ function App() {
void onCallTool(name, args, runAsTask);
}}
onClearToolResult={onClearToolResult}
+ onReadResourceContents={onReadResourceContents}
onRefreshTools={onRefreshTools}
onPromptsUiChange={setPromptsUi}
onGetPrompt={(name, args) => {
diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx
index 4427bdd3..ac861fdd 100644
--- a/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx
+++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx
@@ -74,3 +74,15 @@ export const JsonWithCopy: Story = {
copyable: true,
},
};
+
+export const ResourceLinkBlock: Story = {
+ args: {
+ block: {
+ type: "resource_link",
+ uri: "file:///docs/readme.md",
+ name: "Readme",
+ description: "Project documentation",
+ mimeType: "text/markdown",
+ },
+ },
+};
diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx
index 9e5a26e4..4af024d5 100644
--- a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx
+++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx
@@ -99,6 +99,21 @@ describe("ContentViewer", () => {
expect(screen.getByText("ui://app")).toBeInTheDocument();
});
+ it("renders a resource_link statically (no expand control)", () => {
+ const block: ContentBlock = {
+ 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();
+ });
+
it("renders nothing for unknown block types", () => {
renderWithMantine();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx
index 00d4a68c..8ab5a60b 100644
--- a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx
+++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx
@@ -1,8 +1,9 @@
-import { Code, Flex, Image, Stack, Text } from "@mantine/core";
+import { Code, Flex, Image, Stack } from "@mantine/core";
import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CopyButton } from "../CopyButton/CopyButton";
+import { ResourceLinkInfo } from "../ResourceLinkInfo/ResourceLinkInfo";
export interface ContentViewerProps {
block: ContentBlock;
@@ -131,12 +132,16 @@ export function ContentViewer({
);
case "resource_link":
+ // Static metadata only. The interactive, read-on-demand presentation
+ // lives in the `groups/ResourceLink` group, rendered by content-block
+ // hosts (e.g. ToolResultPanel) that can supply a read handler.
return (
-
-
- {block.name ?? block.uri}
-
-
+
);
default:
return null;
diff --git a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.stories.tsx b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.stories.tsx
new file mode 100644
index 00000000..cdbecd36
--- /dev/null
+++ b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.stories.tsx
@@ -0,0 +1,25 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ResourceLinkInfo } from "./ResourceLinkInfo";
+
+const meta: Meta = {
+ title: "Elements/ResourceLinkInfo",
+ component: ResourceLinkInfo,
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Full: Story = {
+ args: {
+ uri: "file:///docs/readme.md",
+ name: "Readme",
+ description: "Project documentation",
+ mimeType: "text/markdown",
+ },
+};
+
+export const UriOnly: Story = {
+ args: {
+ uri: "demo://resource/dynamic/text/2",
+ },
+};
diff --git a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.test.tsx b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.test.tsx
new file mode 100644
index 00000000..1a3dedc6
--- /dev/null
+++ b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.test.tsx
@@ -0,0 +1,35 @@
+import { describe, it, expect } from "vitest";
+import { renderWithMantine, screen } from "../../../test/renderWithMantine";
+import { ResourceLinkInfo } from "./ResourceLinkInfo";
+
+const URI = "file:///docs/readme.md";
+
+describe("ResourceLinkInfo", () => {
+ it("renders uri, name, description, 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("renders only the uri when optional fields are absent", () => {
+ renderWithMantine();
+ expect(screen.getByText(URI)).toBeInTheDocument();
+ expect(screen.queryByText("text/markdown")).not.toBeInTheDocument();
+ });
+
+ it("renders the action slot when provided", () => {
+ renderWithMantine(
+ chevron} />,
+ );
+ expect(screen.getByText("chevron")).toBeInTheDocument();
+ });
+});
diff --git a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx
new file mode 100644
index 00000000..71e2b5e1
--- /dev/null
+++ b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx
@@ -0,0 +1,90 @@
+import type { ReactNode } from "react";
+import { Badge, Group, Stack, Text } from "@mantine/core";
+
+export interface ResourceLinkInfoProps {
+ /** The linked resource's URI (always shown). */
+ uri: string;
+ /** Optional human-friendly name shown beneath 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.
+ */
+ action?: ReactNode;
+}
+
+const HeaderStack = Stack.withProps({
+ gap: 4,
+});
+
+const UriRow = Group.withProps({
+ justify: "space-between",
+ wrap: "nowrap",
+ gap: "xs",
+ align: "flex-start",
+});
+
+const UriText = Text.withProps({
+ size: "sm",
+ c: "blue",
+ ff: "monospace",
+ variant: "monoBreak",
+ flex: 1,
+ miw: 0,
+});
+
+const MetaGroup = Group.withProps({
+ gap: "xs",
+ wrap: "nowrap",
+});
+
+const MimeBadge = Badge.withProps({
+ size: "sm",
+ variant: "light",
+ color: "blue",
+ // MIME types are conventionally lowercase; keep them as-is rather than
+ // letting Badge's default uppercase transform mangle them.
+ tt: "none",
+});
+
+const NameText = Text.withProps({
+ size: "sm",
+ fw: 600,
+});
+
+const DescriptionText = Text.withProps({
+ size: "sm",
+ c: "dimmed",
+});
+
+/**
+ * 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.
+ */
+export function ResourceLinkInfo({
+ uri,
+ name,
+ description,
+ mimeType,
+ action,
+}: ResourceLinkInfoProps) {
+ return (
+
+
+ {uri}
+
+ {mimeType && {mimeType}}
+ {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
new file mode 100644
index 00000000..5c4dc89b
--- /dev/null
+++ b/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx
@@ -0,0 +1,93 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import { expect, userEvent, waitFor, within } from "storybook/test";
+import { ResourceLink } from "./ResourceLink";
+
+const URI = "file:///docs/readme.md";
+
+const readMarkdown = async (): Promise => ({
+ contents: [
+ {
+ uri: URI,
+ mimeType: "text/markdown",
+ text: "# Readme\n\nRead on demand.",
+ },
+ ],
+});
+
+const BLOB_URI = "demo://resource/session/blob.gz";
+
+const readLargeBlob = async (): Promise => ({
+ contents: [
+ {
+ uri: BLOB_URI,
+ mimeType: "application/gzip",
+ // A long base64 blob so the rendered JSON exceeds the card's max height
+ // and scrolls within it rather than pushing the page down.
+ blob: "H4sIAAAAAAAA".repeat(400),
+ },
+ ],
+});
+
+const meta: Meta = {
+ title: "Groups/ResourceLink",
+ component: ResourceLink,
+};
+
+export default meta;
+type Story = StoryObj;
+
+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();
+ },
+};
+
+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 waitFor(() =>
+ expect(canvas.getByText("Resource:")).toBeInTheDocument(),
+ );
+ },
+};
+
+// A large read result: the inline JSON exceeds the card's max height and
+// scrolls within the bounded area instead of overflowing the card.
+export const LargeResult: Story = {
+ args: {
+ uri: BLOB_URI,
+ name: "Blob Resource",
+ description: "A large gzipped resource",
+ mimeType: "application/gzip",
+ onReadResource: readLargeBlob,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(
+ canvas.getByRole("button", { name: `Expand resource ${BLOB_URI}` }),
+ );
+ await waitFor(() =>
+ expect(canvas.getByText("Resource:")).toBeInTheDocument(),
+ );
+ },
+};
diff --git a/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx b/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx
new file mode 100644
index 00000000..92a276e0
--- /dev/null
+++ b/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx
@@ -0,0 +1,172 @@
+import { describe, it, expect, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ renderWithMantine,
+ screen,
+ waitFor,
+} from "../../../test/renderWithMantine";
+import { ResourceLink } from "./ResourceLink";
+
+const URI = "file:///docs/readme.md";
+
+const readResult = (text: string): ReadResourceResult => ({
+ contents: [{ uri: URI, mimeType: "text/plain", text }],
+});
+
+describe("ResourceLink", () => {
+ it("renders uri, name, description, 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", () => {
+ renderWithMantine();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("reads the resource on demand and renders the result inline", async () => {
+ const user = userEvent.setup();
+ const onReadResource = vi.fn().mockResolvedValue(readResult("hello body"));
+ renderWithMantine(
+ ,
+ );
+
+ const button = screen.getByRole("button", {
+ name: `Expand resource ${URI}`,
+ });
+ expect(button).toHaveAttribute("aria-expanded", "false");
+
+ await user.click(button);
+
+ expect(onReadResource).toHaveBeenCalledWith(URI);
+ await waitFor(() =>
+ expect(screen.getByText("Resource:")).toBeInTheDocument(),
+ );
+ // The full read result is rendered as formatted JSON.
+ expect(screen.getByText(/"hello body"/)).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: `Collapse resource ${URI}` }),
+ ).toHaveAttribute("aria-expanded", "true");
+ });
+
+ it("collapses and re-expands without re-reading (result cached)", async () => {
+ const user = userEvent.setup();
+ const onReadResource = vi.fn().mockResolvedValue(readResult("cached body"));
+ renderWithMantine(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole("button", { name: `Expand resource ${URI}` }),
+ );
+ await waitFor(() =>
+ expect(screen.getByText(/"cached body"/)).toBeInTheDocument(),
+ );
+
+ // Collapse — content is hidden.
+ await user.click(
+ screen.getByRole("button", { name: `Collapse resource ${URI}` }),
+ );
+ expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
+
+ // Re-expand — content returns without a second read.
+ await user.click(
+ screen.getByRole("button", { name: `Expand resource ${URI}` }),
+ );
+ expect(screen.getByText(/"cached body"/)).toBeInTheDocument();
+ expect(onReadResource).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows an alert when the read fails", async () => {
+ const user = userEvent.setup();
+ const onReadResource = vi.fn().mockRejectedValue(new Error("nope"));
+ renderWithMantine(
+ ,
+ );
+
+ await user.click(screen.getByRole("button"));
+ await waitFor(() =>
+ expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
+ );
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+
+ it("retries the read on re-expand after a failure", async () => {
+ const user = userEvent.setup();
+ const onReadResource = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("transient"))
+ .mockResolvedValueOnce(readResult("recovered body"));
+ renderWithMantine(
+ ,
+ );
+
+ // First expand fails.
+ await user.click(
+ screen.getByRole("button", { name: `Expand resource ${URI}` }),
+ );
+ await waitFor(() =>
+ expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
+ );
+
+ // Collapse, then re-expand — the read is retried (error is not cached).
+ await user.click(
+ screen.getByRole("button", { name: `Collapse resource ${URI}` }),
+ );
+ await user.click(
+ screen.getByRole("button", { name: `Expand resource ${URI}` }),
+ );
+
+ await waitFor(() =>
+ expect(screen.getByText(/"recovered body"/)).toBeInTheDocument(),
+ );
+ expect(
+ screen.queryByText("Failed to read resource"),
+ ).not.toBeInTheDocument();
+ expect(onReadResource).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not fire a second read when toggled while a read is in flight", async () => {
+ const user = userEvent.setup();
+ let resolveRead: (value: ReadResourceResult) => void = () => {};
+ const onReadResource = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveRead = resolve;
+ }),
+ );
+ renderWithMantine(
+ ,
+ );
+
+ // Expand — read is in flight (loading), then collapse and re-expand.
+ await user.click(
+ screen.getByRole("button", { name: `Expand resource ${URI}` }),
+ );
+ await user.click(
+ screen.getByRole("button", { name: `Collapse resource ${URI}` }),
+ );
+ await user.click(
+ screen.getByRole("button", { name: `Expand resource ${URI}` }),
+ );
+
+ // Still only the original in-flight read — no redundant fetch.
+ expect(onReadResource).toHaveBeenCalledTimes(1);
+
+ resolveRead(readResult("in flight body"));
+ await waitFor(() =>
+ expect(screen.getByText(/"in flight body"/)).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx b/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx
new file mode 100644
index 00000000..2e474783
--- /dev/null
+++ b/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx
@@ -0,0 +1,174 @@
+import { useState } from "react";
+import {
+ Alert,
+ Loader,
+ Paper,
+ ScrollArea,
+ Stack,
+ Text,
+ UnstyledButton,
+} 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 { 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. */
+ name?: string;
+ /** Optional description shown beneath the name. */
+ description?: string;
+ /** Optional MIME type shown as a badge. */
+ mimeType?: string;
+ /**
+ * Read-on-demand handler. When provided, the card becomes expandable: the
+ * first expand calls this with the link's `uri` and renders the returned
+ * read result inline. Omit to render a static, non-expandable card.
+ */
+ onReadResource?: (uri: string) => Promise;
+}
+
+const LinkCard = Paper.withProps({
+ withBorder: true,
+ p: "sm",
+ radius: "md",
+});
+
+const HeaderButton = UnstyledButton.withProps({
+ w: "100%",
+ ta: "left",
+});
+
+const ExpandedSection = Stack.withProps({
+ gap: "xs",
+ mt: "xs",
+});
+
+// Caps the inline read result so a large resource scrolls within the card
+// instead of pushing the page down — mirrors V1's bounded resource view.
+// `Autosize` sizes to the content up to `mah`, then scrolls; a plain
+// ScrollArea would need a definite height to scroll, which this card (sized to
+// its content) does not provide.
+const ResultScroll = ScrollArea.Autosize.withProps({
+ mah: 400,
+ type: "auto",
+ scrollbars: "y",
+ offsetScrollbars: true,
+});
+
+const ResourceLabel = Text.withProps({
+ size: "sm",
+ fw: 600,
+ c: "green",
+});
+
+const LoadingText = Text.withProps({
+ size: "sm",
+ c: "dimmed",
+});
+
+/**
+ * Expandable card for a `resource_link` content block. Renders the link's
+ * metadata via {@link ResourceLinkInfo} and — when `onReadResource` is supplied
+ * — an expand affordance that reads the linked resource on demand and renders
+ * the full read result inline as formatted JSON (via {@link ContentViewer}).
+ * The fetched result is cached so collapsing and re-expanding does not re-read.
+ */
+export function ResourceLink({
+ uri,
+ name,
+ description,
+ mimeType,
+ onReadResource,
+}: ResourceLinkProps) {
+ const [expanded, setExpanded] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+
+ const expandable = Boolean(onReadResource);
+
+ async function toggle() {
+ if (!onReadResource) return;
+ if (expanded) {
+ setExpanded(false);
+ return;
+ }
+ setExpanded(true);
+ // Only a successful result is cached; re-expanding after an error retries
+ // the read so a transient failure isn't permanent.
+ if (result !== null) return;
+ // Don't fire a second read if one is already in flight (rapid toggle).
+ if (loading) return;
+ setError(null);
+ setLoading(true);
+ try {
+ setResult(await onReadResource(uri));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const Chevron = expanded ? RiArrowDownSLine : RiArrowRightSLine;
+ const action = expandable ? (
+ loading ? (
+
+ ) : (
+
+ )
+ ) : undefined;
+
+ const info = (
+
+ );
+
+ 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:
+
+
+
+ >
+ ) : null}
+
+ )}
+
+ );
+}
diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx
index ee05c641..5e2318db 100644
--- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx
+++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx
@@ -1,7 +1,11 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
-import { renderWithMantine, screen } from "../../../test/renderWithMantine";
+import {
+ renderWithMantine,
+ screen,
+ waitFor,
+} from "../../../test/renderWithMantine";
import { ToolResultPanel } from "./ToolResultPanel";
const okResult: CallToolResult = {
@@ -37,6 +41,34 @@ describe("ToolResultPanel", () => {
expect(screen.getByText("No results yet")).toBeInTheDocument();
});
+ it("renders a resource_link block as an expandable ResourceLink", 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(
+ {}}
+ onReadResource={onReadResource}
+ />,
+ );
+ expect(screen.getByText("ok")).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("invokes onClear when Clear is clicked", async () => {
const user = userEvent.setup();
const onClear = vi.fn();
diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx
index a45d72cc..5986d911 100644
--- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx
+++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx
@@ -7,12 +7,21 @@ import {
Text,
Title,
} from "@mantine/core";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import type {
+ CallToolResult,
+ ReadResourceResult,
+} from "@modelcontextprotocol/sdk/types.js";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
+import { ResourceLink } from "../ResourceLink/ResourceLink";
export interface ToolResultPanelProps {
result: CallToolResult;
onClear: () => void;
+ /**
+ * Read-on-demand handler so `resource_link` blocks in the result can fetch
+ * and inline their contents.
+ */
+ onReadResource?: (uri: string) => Promise;
}
const ClearButton = Button.withProps({
@@ -49,7 +58,11 @@ const ResultStack = Stack.withProps({
gap: "md",
});
-export function ToolResultPanel({ result, onClear }: ToolResultPanelProps) {
+export function ToolResultPanel({
+ result,
+ onClear,
+ onReadResource,
+}: ToolResultPanelProps) {
return (
@@ -68,13 +81,24 @@ export function ToolResultPanel({ result, onClear }: ToolResultPanelProps) {
) : result.content.length === 0 ? (
No results yet
) : (
- result.content.map((block, index) => (
-
- ))
+ result.content.map((block, index) =>
+ block.type === "resource_link" ? (
+
+ ) : (
+
+ ),
+ )
)}
diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
index e7989483..32f006e6 100644
--- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
+++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
@@ -1,5 +1,9 @@
import { Card, Flex, Stack, Text } from "@mantine/core";
-import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
+import type {
+ CallToolResult,
+ ReadResourceResult,
+ Tool,
+} from "@modelcontextprotocol/sdk/types.js";
import { ToolControls } from "../../groups/ToolControls/ToolControls";
import {
ToolDetailPanel,
@@ -46,6 +50,11 @@ export interface ToolsScreenProps {
) => void;
onCancelCall?: () => void;
onClearResult?: () => void;
+ /**
+ * Read-on-demand handler for `resource_link` blocks in a tool result.
+ * Passed through to the result panel so links can inline their contents.
+ */
+ onReadResource?: (uri: string) => Promise;
}
// Caps the detail/result columns at the screen's available height: full
@@ -110,6 +119,7 @@ export function ToolsScreen({
onCallTool,
onCancelCall,
onClearResult,
+ onReadResource,
}: ToolsScreenProps) {
const { selectedToolName, formValues, search } = ui;
const selectedTool = selectedToolName
@@ -177,6 +187,7 @@ export function ToolsScreen({
onClearResult?.()}
+ onReadResource={onReadResource}
/>
) : (
Results will appear here
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx
index 9db889f6..fd06cad7 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx
@@ -5,6 +5,7 @@ import type {
InitializeResult,
LoggingLevel,
Prompt,
+ ReadResourceResult,
Resource,
ResourceTemplate,
Task,
@@ -284,6 +285,11 @@ export interface InspectorViewProps {
onCancelToolCall?: () => void;
onClearToolResult?: () => void;
onRefreshTools: () => void;
+ /**
+ * Read-on-demand handler for `resource_link` blocks in a tool result.
+ * Returns the linked resource's contents so the result panel can inline them.
+ */
+ onReadResourceContents?: (uri: string) => Promise;
onPromptsUiChange: (next: PromptsUiState) => void;
onGetPrompt: (name: string, args: Record) => void;
@@ -388,6 +394,7 @@ export function InspectorView({
onCallTool,
onCancelToolCall,
onClearToolResult,
+ onReadResourceContents,
onRefreshTools,
onPromptsUiChange,
onGetPrompt,
@@ -598,6 +605,7 @@ export function InspectorView({
onCallTool={onCallTool}
onCancelCall={onCancelToolCall}
onClearResult={onClearToolResult}
+ onReadResource={onReadResourceContents}
/>