Skip to content

Commit 6678313

Browse files
cliffhallclaude
andauthored
Expose live resource subscriptions via a hook (#1325) (#1326)
* Expose live resource subscriptions via a hook (#1325) Add ResourceSubscriptionsState that mirrors InspectorClient's subscribed URIs as InspectorResourceSubscription[], resolving each URI against the managed resources list (so the subscription tile shows server-supplied name/title) and stamping lastUpdated on notifications/resources/updated. Pair it with useResourceSubscriptions and wire App.tsx so the Resources screen reflects subscribe/unsubscribe actions in real time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address review notes on resourceSubscriptionsState - Soften the onResourceUpdated comment: the client's dispatch is already guarded by subscribedResources.has(uri), so the re-check is true defense-in-depth rather than guarding a known hazard. - Use this.getSubscriptions() in the statusChange handler so every emit goes through the defensive-copy path. - Document the deliberate fallback to a synthetic Resource when a previously-listed resource is removed from the managed list while the user is still subscribed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Reflect subscribed state in preview + compact subscribed tile labels - Wire `isSubscribed` on the ReadResourceState passed to ResourcesScreen by deriving it from the live subscriptions list in App.tsx. The ResourcePreviewPanel's SubscribeButton already flips its label to "Unsubscribe" when subscribed; without this derivation isSubscribed was always false and the button looked stuck on "Subscribe". - In ResourceSubscribedItem, display only the last URI path segment (truncated with ellipsis if it still overflows) and surface the full URI via a hover tooltip. Keeps the Subscriptions pleat readable when resources have long path-style URIs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Auto-read resource on sidebar click Clicking a resource in the URIs accordion now triggers onReadResource in addition to setting the selection, so the preview panel jumps straight to the pending → ok render path. Removes the unreachable "Click to read this resource" placeholder, since the loader / preview / error states always cover the rendered output for a selected resource. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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> * Contain resource preview: sticky header/footer, scrollable body Restructure ResourcePreviewPanel into a fixed-height column with the resource title + URI pinned to the top and the timestamp / annotations / subscribe-refresh actions pinned to the bottom. The content viewer area in the middle now owns its own ScrollArea, so a long markdown body scrolls within the panel instead of pushing the subscribe button below the viewport. In ResourcesScreen, the selectedResource branch (and the template branch's right pane) now hosts the panel inside a PreviewPane Flex column with the screen's max-height, and renderReadState wraps in a FillDetailCard sized to fill that column. The legacy outer ScrollArea.Autosize is removed for these panes since scrolling is internal now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Auto-hide template after read; size preview to content with cap - After the user clicks Read Resource on the template form, handleReadResource now clears selectedTemplateUri so the screen swaps from the template form to the resource preview. Previously both panels stayed mounted side-by-side. - The preview no longer hard-fills the viewport. The Card uses a new "preview" theme variant (overflow: hidden) and is content-sized, capped at SCROLL_MAX_HEIGHT. ResourcePreviewPanel's flex column marks the header / meta / footer rows as `flex: 0 0 auto` and the ContentScroll as `flex: 0 1 auto` with `mih: 0`, so: - short content → card hugs it, footer sits right under the body - long content → card caps at viewport, ContentScroll shrinks and scrolls internally, footer stays pinned at the cap - ScrollArea / Group imports in ResourcesScreen pruned to match the simpler single-pane layout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Cap template form panel at 40% width A bare text input + Read Resource button stretched across the whole main content area looks awkward on wide displays. Apply maw=40% to the PreviewPane in the template branch so the form keeps a reasonable form-field width regardless of viewport. The preview branch is unaffected — resource bodies still get the full main area for content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Live completion/complete suggestions on resource template inputs Wires the server's `completions` capability through to the resource template form so each variable's input becomes an Autocomplete that fires `completion/complete` (debounced, 300ms) on every keystroke and surfaces the returned values as a dropdown — mirroring v1's behavior. - core/mcp/inspectorClientProtocol.ts: surface getCompletions on the protocol so non-runtime callers (state managers, hooks, tests) can depend on it. FakeInspectorClient gains a vi.fn-backed stub. - ResourceTemplatePanel: accepts onCompleteArgument + completionsSupported. When both are present it renders Mantine Autocomplete instead of TextInput, debounces keystrokes via per-arg timers, aborts in-flight requests on the next keystroke, and disables client-side filtering (the server already filtered for the typed prefix). - ResourcesScreen / InspectorView: thread the props through; the screen-level callback re-injects the active template's URI as the `ref: "ref/resource"` so the panel-level callback stays ref-free. - App.tsx: wires onCompleteArgument to inspectorClient.getCompletions and derives completionsSupported from `capabilities?.completions`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4d74b94 commit 6678313

23 files changed

Lines changed: 2752 additions & 145 deletions

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/App.tsx

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsSta
1616
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js";
1717
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js";
1818
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js";
19+
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
1920
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
2021
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js";
2122
import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
@@ -26,6 +27,7 @@ import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
2627
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
2728
import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js";
2829
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
30+
import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js";
2931
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
3032
import { InspectorView } from "./components/views/InspectorView/InspectorView";
3133
import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen";
@@ -173,6 +175,8 @@ function App() {
173175
useState<ManagedResourceTemplatesState | null>(null);
174176
const [managedRequestorTasksState, setManagedRequestorTasksState] =
175177
useState<ManagedRequestorTasksState | null>(null);
178+
const [resourceSubscriptionsState, setResourceSubscriptionsState] =
179+
useState<ResourceSubscriptionsState | null>(null);
176180
const [messageLogState, setMessageLogState] =
177181
useState<MessageLogState | null>(null);
178182
const [fetchRequestLogState, setFetchRequestLogState] =
@@ -237,6 +241,9 @@ function App() {
237241
inspectorClient,
238242
managedRequestorTasksState,
239243
);
244+
const { subscriptions } = useResourceSubscriptions(
245+
resourceSubscriptionsState,
246+
);
240247
const { messages } = useMessageLog(messageLogState);
241248

242249
// Capture observed handshake latency at the connecting → connected edge.
@@ -304,6 +311,7 @@ function App() {
304311
managedResourcesState?.destroy();
305312
managedResourceTemplatesState?.destroy();
306313
managedRequestorTasksState?.destroy();
314+
resourceSubscriptionsState?.destroy();
307315
messageLogState?.destroy();
308316
fetchRequestLogState?.destroy();
309317
stderrLogState?.destroy();
@@ -325,11 +333,19 @@ function App() {
325333
setInspectorClient(client);
326334
setManagedToolsState(new ManagedToolsState(client));
327335
setManagedPromptsState(new ManagedPromptsState(client));
328-
setManagedResourcesState(new ManagedResourcesState(client));
336+
const nextResourcesState = new ManagedResourcesState(client);
337+
setManagedResourcesState(nextResourcesState);
329338
setManagedResourceTemplatesState(
330339
new ManagedResourceTemplatesState(client),
331340
);
332341
setManagedRequestorTasksState(new ManagedRequestorTasksState(client));
342+
// ResourceSubscriptionsState consults the managed resources list to
343+
// resolve subscribed URIs to full Resource objects (so the subscription
344+
// tile shows the server-supplied name/title). Pass the freshly created
345+
// state to avoid the React update lag from setManagedResourcesState.
346+
setResourceSubscriptionsState(
347+
new ResourceSubscriptionsState(client, nextResourcesState),
348+
);
333349
setMessageLogState(new MessageLogState(client));
334350
setFetchRequestLogState(new FetchRequestLogState(client));
335351
setStderrLogState(new StderrLogState(client));
@@ -342,6 +358,7 @@ function App() {
342358
managedResourcesState,
343359
managedResourceTemplatesState,
344360
managedRequestorTasksState,
361+
resourceSubscriptionsState,
345362
messageLogState,
346363
fetchRequestLogState,
347364
stderrLogState,
@@ -491,6 +508,27 @@ function App() {
491508
[inspectorClient],
492509
);
493510

511+
const onCompleteArgument = useCallback(
512+
async (
513+
ref:
514+
| { type: "ref/resource"; uri: string }
515+
| { type: "ref/prompt"; name: string },
516+
argumentName: string,
517+
argumentValue: string,
518+
context: Record<string, string>,
519+
): Promise<string[]> => {
520+
if (!inspectorClient) return [];
521+
const result = await inspectorClient.getCompletions(
522+
ref,
523+
argumentName,
524+
argumentValue,
525+
context,
526+
);
527+
return result.values;
528+
},
529+
[inspectorClient],
530+
);
531+
494532
const onCancelTask = useCallback(
495533
(taskId: string) => {
496534
if (!inspectorClient) return;
@@ -545,6 +583,22 @@ function App() {
545583
/* TODO: not wired yet */
546584
}, []);
547585

586+
// The Resources screen needs `isSubscribed` to flip the Subscribe button
587+
// label to "Unsubscribe". Derive it from the live subscriptions list rather
588+
// than threading it through every setReadResourceState site — that way the
589+
// button reflects state changes from any source (preview panel, subscribed
590+
// tile, or future server-initiated subscribe notifications).
591+
const effectiveReadResourceState = useMemo<
592+
ReadResourceState | undefined
593+
>(() => {
594+
if (!readResourceState) return undefined;
595+
if (!readResourceState.uri) return readResourceState;
596+
const isSubscribed = subscriptions.some(
597+
(s) => s.resource.uri === readResourceState.uri,
598+
);
599+
return { ...readResourceState, isSubscribed };
600+
}, [readResourceState, subscriptions]);
601+
548602
return (
549603
<InspectorView
550604
servers={servers}
@@ -557,16 +611,13 @@ function App() {
557611
prompts={prompts}
558612
resources={resources}
559613
resourceTemplates={resourceTemplates}
560-
// TODO(#1325): drop the empty fallback once `useResourceSubscriptions`
561-
// surfaces the live subscription list — subscribe/unsubscribe buttons
562-
// currently fire but the screen never reflects the result.
563-
subscriptions={[]}
614+
subscriptions={subscriptions}
564615
logs={logs}
565616
tasks={tasks}
566617
history={messages}
567618
toolCallState={toolCallState}
568619
getPromptState={getPromptState}
569-
readResourceState={readResourceState}
620+
readResourceState={effectiveReadResourceState}
570621
currentLogLevel={currentLogLevel}
571622
sandboxPath={STUB_SANDBOX_PATH}
572623
bridgeFactory={stubBridgeFactory}
@@ -600,6 +651,8 @@ function App() {
600651
onSubscribeResource={onSubscribeResource}
601652
onUnsubscribeResource={onUnsubscribeResource}
602653
onRefreshResources={onRefreshResources}
654+
onCompleteArgument={onCompleteArgument}
655+
completionsSupported={capabilities?.completions !== undefined}
603656
onCancelTask={onCancelTask}
604657
onClearCompletedTasks={todoNoop}
605658
onRefreshTasks={onRefreshTasks}

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
});

0 commit comments

Comments
 (0)