diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx
index 84b83209..f8a5f9f8 100644
--- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx
+++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { fn } from "storybook/test";
+import { expect, fn, userEvent, within } from "storybook/test";
import { ResourcePreviewPanel } from "./ResourcePreviewPanel";
const meta: Meta = {
@@ -108,6 +108,44 @@ export const SubscriptionsUnsupported: Story = {
},
};
+// Markdown, CSV, and HTML resources expose a "View Source" link (left of
+// Refresh) that swaps the rendered preview for the raw resource text. The play
+// function drives the toggle: rendered heading → raw source → back.
+export const MarkdownWithViewSource: Story = {
+ args: {
+ resource: {
+ name: "README.md",
+ uri: "file:///README.md",
+ },
+ contents: [
+ {
+ uri: "file:///README.md",
+ mimeType: "text/markdown",
+ text: "# Project\n\nA **bold** intro with a [link](https://example.com).",
+ },
+ ],
+ isSubscribed: false,
+ lastUpdated: new Date("2026-03-17T10:30:00Z"),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // Rendered by default.
+ await expect(
+ canvas.getByRole("heading", { level: 1, name: "Project" }),
+ ).toBeInTheDocument();
+ // Switch to raw source.
+ await userEvent.click(canvas.getByRole("button", { name: "View Source" }));
+ await expect(canvas.getByText(/# Project/)).toBeInTheDocument();
+ // Switch back to rendered.
+ await userEvent.click(
+ canvas.getByRole("button", { name: "View Rendered" }),
+ );
+ await expect(
+ canvas.getByRole("heading", { level: 1, name: "Project" }),
+ ).toBeInTheDocument();
+ },
+};
+
export const WithAnnotations: Story = {
args: {
resource: {
diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx
index 36821a83..23e1070b 100644
--- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx
+++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx
@@ -306,6 +306,168 @@ describe("ResourcePreviewPanel", () => {
expect(screen.getByText("# not a heading")).toBeInTheDocument();
});
+ describe("View Source toggle", () => {
+ const markdownProps = {
+ ...baseProps,
+ resource: { name: "readme", uri: "file:///readme.md" },
+ contents: [
+ {
+ uri: "file:///readme.md",
+ mimeType: "text/markdown",
+ text: "# Hello",
+ },
+ ],
+ };
+
+ it("toggles a markdown preview between rendered and raw source", async () => {
+ const user = userEvent.setup();
+ renderWithMantine();
+
+ // Rendered by default: heading shown, no raw "# Hello" text.
+ expect(
+ screen.getByRole("heading", { level: 1, name: "Hello" }),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "View Source" }));
+
+ // Source mode: raw markdown text shown, heading gone, label flipped.
+ expect(screen.getByText("# Hello")).toBeInTheDocument();
+ expect(
+ screen.queryByRole("heading", { level: 1 }),
+ ).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "View Rendered" }));
+
+ // Back to rendered.
+ expect(
+ screen.getByRole("heading", { level: 1, name: "Hello" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "View Source" }),
+ ).toBeInTheDocument();
+ });
+
+ it("reflects toggle state via aria-pressed", async () => {
+ const user = userEvent.setup();
+ renderWithMantine();
+ expect(
+ screen.getByRole("button", { name: "View Source" }),
+ ).toHaveAttribute("aria-pressed", "false");
+ await user.click(screen.getByRole("button", { name: "View Source" }));
+ expect(
+ screen.getByRole("button", { name: "View Rendered" }),
+ ).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("only switches source-toggleable items in a mixed multi-part resource", async () => {
+ const user = userEvent.setup();
+ renderWithMantine(
+ ,
+ );
+ // Toggle gated on the first (markdown) item.
+ await user.click(screen.getByRole("button", { name: "View Source" }));
+ // Markdown switches to raw text...
+ expect(screen.getByText("# Hi")).toBeInTheDocument();
+ // ...but the image is not forced through the text decoder — it still
+ // renders as an image rather than garbled bytes or a binary notice.
+ const img = screen
+ .getAllByRole("img")
+ .find((el) => el.getAttribute("src")?.startsWith("data:image/png"));
+ expect(img).toBeDefined();
+ });
+
+ it("offers the toggle for CSV resources", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.getByRole("button", { name: "View Source" }),
+ ).toBeInTheDocument();
+ });
+
+ it("offers the toggle for HTML resources", () => {
+ vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:url");
+ vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
+ renderWithMantine(
+ hi
" }]}
+ />,
+ );
+ expect(
+ screen.getByRole("button", { name: "View Source" }),
+ ).toBeInTheDocument();
+ vi.restoreAllMocks();
+ });
+
+ it("hides the toggle for non-toggleable kinds (JSON, plain text, image)", () => {
+ const { rerender } = renderWithMantine(
+ ,
+ );
+ // JSON
+ expect(
+ screen.queryByRole("button", { name: "View Source" }),
+ ).not.toBeInTheDocument();
+ // Plain text
+ rerender(
+ ,
+ );
+ expect(
+ screen.queryByRole("button", { name: "View Source" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("resets to the rendered view when the resource changes", async () => {
+ const user = userEvent.setup();
+ const { rerender } = renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "View Source" }));
+ expect(
+ screen.getByRole("button", { name: "View Rendered" }),
+ ).toBeInTheDocument();
+
+ // Switch to a different markdown resource — the toggle should reset.
+ rerender(
+ ,
+ );
+ expect(
+ screen.getByRole("button", { name: "View Source" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 1, name: "Two" }),
+ ).toBeInTheDocument();
+ });
+ });
+
describe("URI-suffix MIME inference", () => {
it("infers text/csv from a .csv URI and renders a table", () => {
renderWithMantine(
diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx
index 015f4891..f74c2904 100644
--- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx
+++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx
@@ -8,6 +8,7 @@ import {
Text,
Title,
} from "@mantine/core";
+import { useState } from "react";
import type {
BlobResourceContents,
Resource,
@@ -15,6 +16,7 @@ import type {
} from "@modelcontextprotocol/sdk/types.js";
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
+import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils";
import { CopyButton } from "../../elements/CopyButton/CopyButton";
import { SubscribeButton } from "../../elements/SubscribeButton/SubscribeButton";
@@ -44,6 +46,19 @@ function formatLastUpdated(date: Date): string {
return `Last updated: ${date.toLocaleString()}`;
}
+// MIME kinds whose rendered preview (react-markdown, the CSV table, the
+// sandboxed HTML iframe) hides the underlying text. For these the panel offers
+// a "View Source" toggle that swaps the renderer for the raw resource text.
+const SOURCE_TOGGLEABLE_KINDS = new Set(["markdown", "csv", "html"]);
+
+// MIME forced on ContentViewer in source mode so it routes through the plain
+// preformatted-text renderer regardless of the resource's real type.
+const SOURCE_MIME = "text/plain";
+
+function isSourceToggleable(mimeType: string): boolean {
+ return SOURCE_TOGGLEABLE_KINDS.has(getMimeKind(mimeType));
+}
+
const HeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
@@ -95,6 +110,14 @@ const ActionGroup = Group.withProps({
gap: "xs",
});
+// Subtle footer action button. Shared by Refresh and the View Source toggle so
+// the two stay visually identical (the toggle is deliberately styled to match
+// Refresh, which sits immediately to its right).
+const FooterButton = Button.withProps({
+ variant: "subtle",
+ size: "sm",
+});
+
const Spacer = Flex.withProps({});
// The panel sizes to its content: when the resource body is short the
@@ -180,6 +203,17 @@ export function ResourcePreviewPanel({
const { uri, annotations } = resource;
const mimeType = effectiveMime(contents[0]?.mimeType, resource);
+ const [showSource, setShowSource] = useState(false);
+ // Reset to the rendered view when the previewed resource changes (the panel
+ // is reused, not remounted, across resources). React's documented
+ // "adjust state during render" pattern — no effect, so no cascading render.
+ const [prevUri, setPrevUri] = useState(uri);
+ if (uri !== prevUri) {
+ setPrevUri(uri);
+ setShowSource(false);
+ }
+ const sourceToggleable = isSourceToggleable(mimeType);
+
return (
@@ -196,14 +230,26 @@ export function ResourcePreviewPanel({
- {contents.map((item, index) => (
-
- ))}
+ {contents.map((item, index) => {
+ const itemMime = effectiveMime(item.mimeType, resource);
+ // The toggle is gated on the first content item but applies per
+ // item: in source mode only the source-toggleable items (the ones
+ // whose rendered view hides their text) switch to plain text, so a
+ // mixed multi-part resource doesn't force an image/PDF blob through
+ // the text decoder.
+ const renderMime =
+ showSource && isSourceToggleable(itemMime)
+ ? SOURCE_MIME
+ : itemMime;
+ return (
+
+ );
+ })}
@@ -224,9 +270,15 @@ export function ResourcePreviewPanel({
)}
-
+ {sourceToggleable && (
+ setShowSource((shown) => !shown)}
+ >
+ {showSource ? "View Rendered" : "View Source"}
+
+ )}
+ Refresh
{subscriptionsSupported && (