From 8d4363d0ea3c48cd7ea95b5c88d7ed702254a8c6 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Sat, 13 Jun 2026 17:15:25 -0400 Subject: [PATCH] feat(web): expandable resource_link with read-on-demand (#1449) (#1456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): expandable resource_link with read-on-demand (#1449) Replace the placeholder `resource_link` rendering in ContentViewer with a dedicated, Mantine-native `ResourceLink` element that mirrors V1's behavior: - Shows uri (monospace, link-styled), optional name/description, and a mimeType badge. - Expand/collapse affordance (chevron + loading spinner) that is keyboard accessible (native button: role, aria-expanded, aria-label, Enter/Space). - On first expand, reads the linked resource on demand via an injected callback and renders the full read result inline as formatted JSON under a green "Resource:" label, capped to a max height with scroll for large results. The fetched result is cached across collapse/expand. The read handler is threaded as a "dumb component + callback as prop" through ToolResultPanel → ToolsScreen → InspectorView, with App supplying a handler that returns the `ReadResourceResult` from `inspectorClient.readResource`. Also extract the shared `resourceContentsToBlock` helper into `utils/resourceContents.ts` (previously private to ResourcePreviewPanel). Tests: unit coverage for ResourceLink (collapsed/expanded/error/cache) and the ContentViewer wiring, plus Storybook stories with play functions. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): scroll large resource_link read results within the card A plain ScrollArea with only `mah` does not scroll when its parent (the content-sized card) has no definite height, so a large read result overflowed the card instead of scrolling. Switch to ScrollArea.Autosize, which grows with content up to the max height and then scrolls. Adds a LargeResult story covering the bounded-scroll behavior. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(web): make ResourceLink a group; extract ResourceLinkInfo element ResourceLink is stateful (expand/loading/cached result) and fetches data via a callback — group-like behavior, and the only `elements/` component using useState. Move it to `groups/` and split out the pure-display metadata as a new `elements/ResourceLinkInfo` element (uri/name/description/mime + an optional action slot). This keeps the layer dependencies clean (no element→group import, which moving ResourceLink alone would have introduced): - ContentViewer (element) renders ResourceLinkInfo (element) statically for `resource_link` blocks and no longer takes an onReadResource prop. - ToolResultPanel (group) renders the interactive ResourceLink (group) for `resource_link` blocks, ContentViewer for the rest. - ResourceLink (group) composes ResourceLinkInfo + ContentViewer. Also reverts the now-single-consumer resourceContents.ts extraction, restoring ResourcePreviewPanel's local toContentBlock. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): make ResourceLink read retryable and guard double-fetch Address PR review: a failed read is no longer cached as terminal — only a successful result is cached, so collapsing and re-expanding retries the read after a transient failure. Add a loading guard so toggling while a read is in flight does not fire a redundant second read. Co-Authored-By: Claude Opus 4.8 (1M context) * style(web): prettier formatting for ResourceLink test Co-Authored-By: Claude Opus 4.8 (1M context) * chore: remove accidentally-committed build artifacts; gitignore clients/*/build A stray 'git add -A' swept 8 compiled build files into d25c9382. Remove them from tracking and add clients/*/build to .gitignore so client build output is never committed again. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .gitignore | 1 + clients/web/src/App.tsx | 14 ++ .../ContentViewer/ContentViewer.stories.tsx | 12 ++ .../ContentViewer/ContentViewer.test.tsx | 15 ++ .../elements/ContentViewer/ContentViewer.tsx | 17 +- .../ResourceLinkInfo.stories.tsx | 25 +++ .../ResourceLinkInfo.test.tsx | 35 ++++ .../ResourceLinkInfo/ResourceLinkInfo.tsx | 90 +++++++++ .../ResourceLink/ResourceLink.stories.tsx | 93 ++++++++++ .../groups/ResourceLink/ResourceLink.test.tsx | 172 +++++++++++++++++ .../groups/ResourceLink/ResourceLink.tsx | 174 ++++++++++++++++++ .../ToolResultPanel/ToolResultPanel.test.tsx | 34 +++- .../ToolResultPanel/ToolResultPanel.tsx | 42 ++++- .../screens/ToolsScreen/ToolsScreen.tsx | 13 +- .../views/InspectorView/InspectorView.tsx | 8 + 15 files changed, 728 insertions(+), 17 deletions(-) create mode 100644 clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.stories.tsx create mode 100644 clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.test.tsx create mode 100644 clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx create mode 100644 clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx create mode 100644 clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx create mode 100644 clients/web/src/components/groups/ResourceLink/ResourceLink.tsx 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} />