Skip to content

Commit ee4ea54

Browse files
cliffhallclaude
andcommitted
Render markdown resources via react-markdown + fix preview overflow
- Add react-markdown / remark-gfm dependencies. - ContentViewer accepts an optional mimeType prop; when it matches text/markdown (or text/x-markdown), text blocks render via react-markdown wrapped in a `.markdown-content` container. Non-markdown text now uses the existing Code "wrapping" variant so long lines stay inside the panel. - ResourcePreviewPanel infers `text/markdown` from a `.md` / `.markdown` URI suffix when the server didn't supply a mimeType, and threads the effective mime through to ContentViewer per content item. - ResourcesScreen flex children get miw=0 so a long unwrappable line in the resource body can no longer push the preview past the viewport's right edge. - App.css gains a `.markdown-content` ruleset constraining nested pre, table, code, and img elements to the container's width. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 13b03dd commit ee4ea54

8 files changed

Lines changed: 1726 additions & 66 deletions

File tree

clients/web/package-lock.json

Lines changed: 1514 additions & 57 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

clients/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
"react": "^19.2.4",
4040
"react-dom": "^19.2.4",
4141
"react-icons": "^5.6.0",
42+
"react-markdown": "^10.1.0",
43+
"remark-gfm": "^4.0.1",
4244
"zod": "^4.3.6",
4345
"zustand": "^5.0.13"
4446
},

clients/web/src/App.css

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,38 @@
147147
.grid-align-start {
148148
align-items: start;
149149
}
150+
151+
/* ── Markdown content (third-party HTML from react-markdown) ───── */
152+
153+
.markdown-content {
154+
max-width: 100%;
155+
overflow-wrap: anywhere;
156+
}
157+
158+
.markdown-content > :first-child {
159+
margin-top: 0;
160+
}
161+
162+
.markdown-content > :last-child {
163+
margin-bottom: 0;
164+
}
165+
166+
.markdown-content pre {
167+
max-width: 100%;
168+
overflow-x: auto;
169+
}
170+
171+
.markdown-content code {
172+
word-break: break-word;
173+
}
174+
175+
.markdown-content table {
176+
display: block;
177+
max-width: 100%;
178+
overflow-x: auto;
179+
}
180+
181+
.markdown-content img {
182+
max-width: 100%;
183+
height: auto;
184+
}

clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,42 @@ describe("ContentViewer", () => {
104104
expect(screen.queryByRole("img")).not.toBeInTheDocument();
105105
expect(screen.queryByRole("button")).not.toBeInTheDocument();
106106
});
107+
108+
it("renders text as markdown when mimeType is text/markdown", () => {
109+
const block: ContentBlock = {
110+
type: "text",
111+
text: "# Title\n\nSome **bold** text.",
112+
};
113+
renderWithMantine(<ContentViewer block={block} mimeType="text/markdown" />);
114+
expect(
115+
screen.getByRole("heading", { level: 1, name: "Title" }),
116+
).toBeInTheDocument();
117+
expect(screen.getByText("bold")).toBeInTheDocument();
118+
});
119+
120+
it("accepts mimeType with parameters (e.g. text/markdown; charset=utf-8)", () => {
121+
const block: ContentBlock = { type: "text", text: "# Heading" };
122+
renderWithMantine(
123+
<ContentViewer block={block} mimeType="text/markdown; charset=utf-8" />,
124+
);
125+
expect(
126+
screen.getByRole("heading", { level: 1, name: "Heading" }),
127+
).toBeInTheDocument();
128+
});
129+
130+
it("falls back to code rendering for non-markdown mime types", () => {
131+
const block: ContentBlock = { type: "text", text: "# not markdown" };
132+
renderWithMantine(<ContentViewer block={block} mimeType="text/plain" />);
133+
expect(screen.getByText("# not markdown")).toBeInTheDocument();
134+
// No <h1> generated by react-markdown
135+
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
136+
});
137+
138+
it("renders a copy overlay for markdown content when copyable", () => {
139+
const block: ContentBlock = { type: "text", text: "# hi" };
140+
renderWithMantine(
141+
<ContentViewer block={block} mimeType="text/markdown" copyable />,
142+
);
143+
expect(screen.getByRole("button")).toBeInTheDocument();
144+
});
107145
});

clients/web/src/components/elements/ContentViewer/ContentViewer.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import { Code, Flex, Image, Stack, Text } from "@mantine/core";
22
import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
3+
import ReactMarkdown from "react-markdown";
4+
import remarkGfm from "remark-gfm";
35
import { CopyButton } from "../CopyButton/CopyButton";
46

57
export interface ContentViewerProps {
68
block: ContentBlock;
79
copyable?: boolean;
10+
/**
11+
* Optional MIME type for the block. When `text/markdown` (or
12+
* `text/x-markdown`), text content is rendered via react-markdown
13+
* instead of as preformatted code.
14+
*/
15+
mimeType?: string;
816
}
917

1018
function formatJson(content: string): string {
@@ -21,6 +29,12 @@ function isJsonText(block: ContentBlock): boolean {
2129
return trimmed.startsWith("{") || trimmed.startsWith("[");
2230
}
2331

32+
function isMarkdownMime(mimeType: string | undefined): boolean {
33+
if (!mimeType) return false;
34+
const base = mimeType.split(";")[0].trim().toLowerCase();
35+
return base === "text/markdown" || base === "text/x-markdown";
36+
}
37+
2438
function buildDataUri(mimeType: string, data: string): string {
2539
return `data:${mimeType};base64,${data}`;
2640
}
@@ -36,21 +50,49 @@ const CopyOverlay = Flex.withProps({
3650
right: 4,
3751
});
3852

53+
const MarkdownWrapper = Flex.withProps({
54+
className: "markdown-content",
55+
direction: "column",
56+
});
57+
3958
const PreviewImage = Image.withProps({
4059
alt: "Content preview",
4160
maw: 400,
4261
radius: "md",
4362
});
4463

45-
export function ContentViewer({ block, copyable = false }: ContentViewerProps) {
64+
export function ContentViewer({
65+
block,
66+
copyable = false,
67+
mimeType,
68+
}: ContentViewerProps) {
4669
switch (block.type) {
4770
case "text": {
71+
const renderAsMarkdown = isMarkdownMime(mimeType);
72+
if (renderAsMarkdown) {
73+
return (
74+
<Stack gap="xs">
75+
<ContentWrapper>
76+
<MarkdownWrapper>
77+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
78+
{block.text}
79+
</ReactMarkdown>
80+
</MarkdownWrapper>
81+
{copyable && (
82+
<CopyOverlay>
83+
<CopyButton value={block.text} />
84+
</CopyOverlay>
85+
)}
86+
</ContentWrapper>
87+
</Stack>
88+
);
89+
}
4890
const isJson = isJsonText(block);
4991
const displayText = isJson ? formatJson(block.text) : block.text;
5092
return (
5193
<Stack gap="xs">
5294
<ContentWrapper>
53-
<Code block p={36}>
95+
<Code block p={36} variant="wrapping">
5496
{displayText}
5597
</Code>
5698
{copyable && (

clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,59 @@ describe("ResourcePreviewPanel", () => {
199199
);
200200
expect(screen.getByText("text/markdown")).toBeInTheDocument();
201201
});
202+
203+
it("renders text/markdown content as markdown", () => {
204+
renderWithMantine(
205+
<ResourcePreviewPanel
206+
{...baseProps}
207+
resource={{ name: "readme", uri: "file:///readme.md" }}
208+
contents={[
209+
{
210+
uri: "file:///readme.md",
211+
mimeType: "text/markdown",
212+
text: "# Hello",
213+
},
214+
]}
215+
/>,
216+
);
217+
expect(
218+
screen.getByRole("heading", { level: 1, name: "Hello" }),
219+
).toBeInTheDocument();
220+
});
221+
222+
it("infers markdown from a .md URI when mimeType is missing", () => {
223+
renderWithMantine(
224+
<ResourcePreviewPanel
225+
{...baseProps}
226+
resource={{ name: "notes", uri: "demo://resource/notes.md" }}
227+
contents={[
228+
{
229+
uri: "demo://resource/notes.md",
230+
text: "## From URI",
231+
},
232+
]}
233+
/>,
234+
);
235+
expect(
236+
screen.getByRole("heading", { level: 2, name: "From URI" }),
237+
).toBeInTheDocument();
238+
});
239+
240+
it("does not render plain-text content as markdown even with markdown-looking text", () => {
241+
renderWithMantine(
242+
<ResourcePreviewPanel
243+
{...baseProps}
244+
resource={{ name: "notes", uri: "file:///notes.txt" }}
245+
contents={[
246+
{
247+
uri: "file:///notes.txt",
248+
mimeType: "text/plain",
249+
text: "# not a heading",
250+
},
251+
]}
252+
/>,
253+
);
254+
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
255+
expect(screen.getByText("# not a heading")).toBeInTheDocument();
256+
});
202257
});

clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,30 @@ const ActionGroup = Group.withProps({
8888

8989
const Spacer = Flex.withProps({});
9090

91+
// Infer a markdown MIME from the URI when the server didn't supply one.
92+
// MCP servers often return `text/plain` (or omit mimeType entirely) for
93+
// `.md` resources; the file extension is the most reliable fallback signal.
94+
function inferMimeFromUri(uri: string): string | undefined {
95+
const path = uri.split("?")[0].split("#")[0];
96+
const lower = path.toLowerCase();
97+
if (lower.endsWith(".md") || lower.endsWith(".markdown")) {
98+
return "text/markdown";
99+
}
100+
return undefined;
101+
}
102+
103+
function effectiveMime(
104+
itemMime: string | undefined,
105+
resource: Resource,
106+
): string {
107+
return (
108+
itemMime ??
109+
resource.mimeType ??
110+
inferMimeFromUri(resource.uri) ??
111+
"application/octet-stream"
112+
);
113+
}
114+
91115
export function ResourcePreviewPanel({
92116
resource,
93117
contents,
@@ -98,8 +122,7 @@ export function ResourcePreviewPanel({
98122
onUnsubscribe,
99123
}: ResourcePreviewPanelProps) {
100124
const { uri, annotations } = resource;
101-
const mimeType =
102-
contents[0]?.mimeType ?? resource.mimeType ?? "application/octet-stream";
125+
const mimeType = effectiveMime(contents[0]?.mimeType, resource);
103126

104127
return (
105128
<Stack gap="md">
@@ -111,7 +134,12 @@ export function ResourcePreviewPanel({
111134
</UriGroup>
112135
</HeaderRow>
113136
{contents.map((item, index) => (
114-
<ContentViewer key={index} block={toContentBlock(item)} copyable />
137+
<ContentViewer
138+
key={index}
139+
block={toContentBlock(item)}
140+
mimeType={effectiveMime(item.mimeType, resource)}
141+
copyable
142+
/>
115143
))}
116144
<MetaRow>
117145
{lastUpdated ? (

clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,16 @@ export function ResourcesScreen({
183183
</Sidebar>
184184

185185
{selectedTemplate ? (
186-
<Group flex={1} gap="md" align="flex-start" wrap="nowrap">
187-
<ScrollArea.Autosize flex={1} mah={SCROLL_MAX_HEIGHT}>
186+
<Group flex={1} miw={0} gap="md" align="flex-start" wrap="nowrap">
187+
<ScrollArea.Autosize flex={1} miw={0} mah={SCROLL_MAX_HEIGHT}>
188188
<DetailCard>
189189
<ResourceTemplatePanel
190190
template={selectedTemplate}
191191
onReadResource={handleReadResource}
192192
/>
193193
</DetailCard>
194194
</ScrollArea.Autosize>
195-
<ScrollArea.Autosize flex={1} mah={SCROLL_MAX_HEIGHT}>
195+
<ScrollArea.Autosize flex={1} miw={0} mah={SCROLL_MAX_HEIGHT}>
196196
{renderReadState() ?? (
197197
<DetailCard>
198198
<EmptyState>Enter a URI and click Read to preview</EmptyState>
@@ -201,7 +201,10 @@ export function ResourcesScreen({
201201
</ScrollArea.Autosize>
202202
</Group>
203203
) : selectedResource ? (
204-
<ScrollArea.Autosize flex={1} mah={SCROLL_MAX_HEIGHT}>
204+
// miw=0 lets the flex item shrink below its content's intrinsic
205+
// width; without it a single long unwrappable line in the resource
206+
// body would push the panel past the viewport's right edge.
207+
<ScrollArea.Autosize flex={1} miw={0} mah={SCROLL_MAX_HEIGHT}>
205208
{renderReadState()}
206209
</ScrollArea.Autosize>
207210
) : (

0 commit comments

Comments
 (0)