Skip to content
Merged
1,571 changes: 1,514 additions & 57 deletions clients/web/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions clients/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"zod": "^4.3.6",
"zustand": "^5.0.13"
},
Expand Down
35 changes: 35 additions & 0 deletions clients/web/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,38 @@
.grid-align-start {
align-items: start;
}

/* ── Markdown content (third-party HTML from react-markdown) ───── */

.markdown-content {
max-width: 100%;
overflow-wrap: anywhere;
}

.markdown-content > :first-child {
margin-top: 0;
}

.markdown-content > :last-child {
margin-bottom: 0;
}

.markdown-content pre {
max-width: 100%;
overflow-x: auto;
}

.markdown-content code {
word-break: break-word;
}

.markdown-content table {
display: block;
max-width: 100%;
overflow-x: auto;
}

.markdown-content img {
max-width: 100%;
height: auto;
}
65 changes: 59 additions & 6 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsSta
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js";
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js";
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js";
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js";
import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
Expand All @@ -26,6 +27,7 @@ import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js";
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js";
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
import { InspectorView } from "./components/views/InspectorView/InspectorView";
import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen";
Expand Down Expand Up @@ -173,6 +175,8 @@ function App() {
useState<ManagedResourceTemplatesState | null>(null);
const [managedRequestorTasksState, setManagedRequestorTasksState] =
useState<ManagedRequestorTasksState | null>(null);
const [resourceSubscriptionsState, setResourceSubscriptionsState] =
useState<ResourceSubscriptionsState | null>(null);
const [messageLogState, setMessageLogState] =
useState<MessageLogState | null>(null);
const [fetchRequestLogState, setFetchRequestLogState] =
Expand Down Expand Up @@ -237,6 +241,9 @@ function App() {
inspectorClient,
managedRequestorTasksState,
);
const { subscriptions } = useResourceSubscriptions(
resourceSubscriptionsState,
);
const { messages } = useMessageLog(messageLogState);

// Capture observed handshake latency at the connecting → connected edge.
Expand Down Expand Up @@ -304,6 +311,7 @@ function App() {
managedResourcesState?.destroy();
managedResourceTemplatesState?.destroy();
managedRequestorTasksState?.destroy();
resourceSubscriptionsState?.destroy();
messageLogState?.destroy();
fetchRequestLogState?.destroy();
stderrLogState?.destroy();
Expand All @@ -325,11 +333,19 @@ function App() {
setInspectorClient(client);
setManagedToolsState(new ManagedToolsState(client));
setManagedPromptsState(new ManagedPromptsState(client));
setManagedResourcesState(new ManagedResourcesState(client));
const nextResourcesState = new ManagedResourcesState(client);
setManagedResourcesState(nextResourcesState);
setManagedResourceTemplatesState(
new ManagedResourceTemplatesState(client),
);
setManagedRequestorTasksState(new ManagedRequestorTasksState(client));
// ResourceSubscriptionsState consults the managed resources list to
// resolve subscribed URIs to full Resource objects (so the subscription
// tile shows the server-supplied name/title). Pass the freshly created
// state to avoid the React update lag from setManagedResourcesState.
setResourceSubscriptionsState(
new ResourceSubscriptionsState(client, nextResourcesState),
);
setMessageLogState(new MessageLogState(client));
setFetchRequestLogState(new FetchRequestLogState(client));
setStderrLogState(new StderrLogState(client));
Expand All @@ -342,6 +358,7 @@ function App() {
managedResourcesState,
managedResourceTemplatesState,
managedRequestorTasksState,
resourceSubscriptionsState,
messageLogState,
fetchRequestLogState,
stderrLogState,
Expand Down Expand Up @@ -491,6 +508,27 @@ function App() {
[inspectorClient],
);

const onCompleteArgument = useCallback(
async (
ref:
| { type: "ref/resource"; uri: string }
| { type: "ref/prompt"; name: string },
argumentName: string,
argumentValue: string,
context: Record<string, string>,
): Promise<string[]> => {
if (!inspectorClient) return [];
const result = await inspectorClient.getCompletions(
ref,
argumentName,
argumentValue,
context,
);
return result.values;
},
[inspectorClient],
);

const onCancelTask = useCallback(
(taskId: string) => {
if (!inspectorClient) return;
Expand Down Expand Up @@ -545,6 +583,22 @@ function App() {
/* TODO: not wired yet */
}, []);

// The Resources screen needs `isSubscribed` to flip the Subscribe button
// label to "Unsubscribe". Derive it from the live subscriptions list rather
// than threading it through every setReadResourceState site — that way the
// button reflects state changes from any source (preview panel, subscribed
// tile, or future server-initiated subscribe notifications).
const effectiveReadResourceState = useMemo<
ReadResourceState | undefined
>(() => {
if (!readResourceState) return undefined;
if (!readResourceState.uri) return readResourceState;
const isSubscribed = subscriptions.some(
(s) => s.resource.uri === readResourceState.uri,
);
return { ...readResourceState, isSubscribed };
}, [readResourceState, subscriptions]);

return (
<InspectorView
servers={servers}
Expand All @@ -557,16 +611,13 @@ function App() {
prompts={prompts}
resources={resources}
resourceTemplates={resourceTemplates}
// TODO(#1325): drop the empty fallback once `useResourceSubscriptions`
// surfaces the live subscription list — subscribe/unsubscribe buttons
// currently fire but the screen never reflects the result.
subscriptions={[]}
subscriptions={subscriptions}
logs={logs}
tasks={tasks}
history={messages}
toolCallState={toolCallState}
getPromptState={getPromptState}
readResourceState={readResourceState}
readResourceState={effectiveReadResourceState}
currentLogLevel={currentLogLevel}
sandboxPath={STUB_SANDBOX_PATH}
bridgeFactory={stubBridgeFactory}
Expand Down Expand Up @@ -600,6 +651,8 @@ function App() {
onSubscribeResource={onSubscribeResource}
onUnsubscribeResource={onUnsubscribeResource}
onRefreshResources={onRefreshResources}
onCompleteArgument={onCompleteArgument}
completionsSupported={capabilities?.completions !== undefined}
onCancelTask={onCancelTask}
onClearCompletedTasks={todoNoop}
onRefreshTasks={onRefreshTasks}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,42 @@ describe("ContentViewer", () => {
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});

it("renders text as markdown when mimeType is text/markdown", () => {
const block: ContentBlock = {
type: "text",
text: "# Title\n\nSome **bold** text.",
};
renderWithMantine(<ContentViewer block={block} mimeType="text/markdown" />);
expect(
screen.getByRole("heading", { level: 1, name: "Title" }),
).toBeInTheDocument();
expect(screen.getByText("bold")).toBeInTheDocument();
});

it("accepts mimeType with parameters (e.g. text/markdown; charset=utf-8)", () => {
const block: ContentBlock = { type: "text", text: "# Heading" };
renderWithMantine(
<ContentViewer block={block} mimeType="text/markdown; charset=utf-8" />,
);
expect(
screen.getByRole("heading", { level: 1, name: "Heading" }),
).toBeInTheDocument();
});

it("falls back to code rendering for non-markdown mime types", () => {
const block: ContentBlock = { type: "text", text: "# not markdown" };
renderWithMantine(<ContentViewer block={block} mimeType="text/plain" />);
expect(screen.getByText("# not markdown")).toBeInTheDocument();
// No <h1> generated by react-markdown
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
});

it("renders a copy overlay for markdown content when copyable", () => {
const block: ContentBlock = { type: "text", text: "# hi" };
renderWithMantine(
<ContentViewer block={block} mimeType="text/markdown" copyable />,
);
expect(screen.getByRole("button")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { Code, Flex, Image, Stack, Text } 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";

export interface ContentViewerProps {
block: ContentBlock;
copyable?: boolean;
/**
* Optional MIME type for the block. When `text/markdown` (or
* `text/x-markdown`), text content is rendered via react-markdown
* instead of as preformatted code.
*/
mimeType?: string;
}

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

function isMarkdownMime(mimeType: string | undefined): boolean {
if (!mimeType) return false;
const base = mimeType.split(";")[0].trim().toLowerCase();
return base === "text/markdown" || base === "text/x-markdown";
}

function buildDataUri(mimeType: string, data: string): string {
return `data:${mimeType};base64,${data}`;
}
Expand All @@ -36,21 +50,49 @@ const CopyOverlay = Flex.withProps({
right: 4,
});

const MarkdownWrapper = Flex.withProps({
className: "markdown-content",
direction: "column",
});

const PreviewImage = Image.withProps({
alt: "Content preview",
maw: 400,
radius: "md",
});

export function ContentViewer({ block, copyable = false }: ContentViewerProps) {
export function ContentViewer({
block,
copyable = false,
mimeType,
}: ContentViewerProps) {
switch (block.type) {
case "text": {
const renderAsMarkdown = isMarkdownMime(mimeType);
if (renderAsMarkdown) {
return (
<Stack gap="xs">
<ContentWrapper>
<MarkdownWrapper>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{block.text}
</ReactMarkdown>
</MarkdownWrapper>
{copyable && (
<CopyOverlay>
<CopyButton value={block.text} />
</CopyOverlay>
)}
</ContentWrapper>
</Stack>
);
}
const isJson = isJsonText(block);
const displayText = isJson ? formatJson(block.text) : block.text;
return (
<Stack gap="xs">
<ContentWrapper>
<Code block p={36}>
<Code block p={36} variant="wrapping">
{displayText}
</Code>
{copyable && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,59 @@ describe("ResourcePreviewPanel", () => {
);
expect(screen.getByText("text/markdown")).toBeInTheDocument();
});

it("renders text/markdown content as markdown", () => {
renderWithMantine(
<ResourcePreviewPanel
{...baseProps}
resource={{ name: "readme", uri: "file:///readme.md" }}
contents={[
{
uri: "file:///readme.md",
mimeType: "text/markdown",
text: "# Hello",
},
]}
/>,
);
expect(
screen.getByRole("heading", { level: 1, name: "Hello" }),
).toBeInTheDocument();
});

it("infers markdown from a .md URI when mimeType is missing", () => {
renderWithMantine(
<ResourcePreviewPanel
{...baseProps}
resource={{ name: "notes", uri: "demo://resource/notes.md" }}
contents={[
{
uri: "demo://resource/notes.md",
text: "## From URI",
},
]}
/>,
);
expect(
screen.getByRole("heading", { level: 2, name: "From URI" }),
).toBeInTheDocument();
});

it("does not render plain-text content as markdown even with markdown-looking text", () => {
renderWithMantine(
<ResourcePreviewPanel
{...baseProps}
resource={{ name: "notes", uri: "file:///notes.txt" }}
contents={[
{
uri: "file:///notes.txt",
mimeType: "text/plain",
text: "# not a heading",
},
]}
/>,
);
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
expect(screen.getByText("# not a heading")).toBeInTheDocument();
});
});
Loading
Loading