Skip to content

Commit a7539d0

Browse files
authored
Merge pull request #564 from nandsha/feat/add-resource-link-support
feat: Add resource_link content type support to ToolResults component
2 parents a27d714 + 914f6bb commit a7539d0

File tree

5 files changed

+252
-2
lines changed

5 files changed

+252
-2
lines changed

client/src/App.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ const App = () => {
8080
ResourceTemplate[]
8181
>([]);
8282
const [resourceContent, setResourceContent] = useState<string>("");
83+
const [resourceContentMap, setResourceContentMap] = useState<
84+
Record<string, string>
85+
>({});
8386
const [prompts, setPrompts] = useState<Prompt[]>([]);
8487
const [promptContent, setPromptContent] = useState<string>("");
8588
const [tools, setTools] = useState<Tool[]>([]);
@@ -461,7 +464,12 @@ const App = () => {
461464
ReadResourceResultSchema,
462465
"resources",
463466
);
464-
setResourceContent(JSON.stringify(response, null, 2));
467+
const content = JSON.stringify(response, null, 2);
468+
setResourceContent(content);
469+
setResourceContentMap((prev) => ({
470+
...prev,
471+
[uri]: content,
472+
}));
465473
};
466474

467475
const subscribeToResource = async (uri: string) => {
@@ -863,6 +871,11 @@ const App = () => {
863871
toolResult={toolResult}
864872
nextCursor={nextToolCursor}
865873
error={errors.tools}
874+
resourceContent={resourceContentMap}
875+
onReadResource={(uri: string) => {
876+
clearError("resources");
877+
readResource(uri);
878+
}}
866879
/>
867880
<ConsoleTab />
868881
<PingTab
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useState, useCallback, useMemo, memo } from "react";
2+
import JsonView from "./JsonView";
3+
4+
interface ResourceLinkViewProps {
5+
uri: string;
6+
name?: string;
7+
description?: string;
8+
mimeType?: string;
9+
resourceContent: string;
10+
onReadResource?: (uri: string) => void;
11+
}
12+
13+
const ResourceLinkView = memo(
14+
({
15+
uri,
16+
name,
17+
description,
18+
mimeType,
19+
resourceContent,
20+
onReadResource,
21+
}: ResourceLinkViewProps) => {
22+
const [{ expanded, loading }, setState] = useState({
23+
expanded: false,
24+
loading: false,
25+
});
26+
27+
const expandedContent = useMemo(
28+
() =>
29+
expanded && resourceContent ? (
30+
<div className="mt-2">
31+
<div className="flex justify-between items-center mb-1">
32+
<span className="font-semibold text-green-600">Resource:</span>
33+
</div>
34+
<JsonView data={resourceContent} className="bg-background" />
35+
</div>
36+
) : null,
37+
[expanded, resourceContent],
38+
);
39+
40+
const handleClick = useCallback(() => {
41+
if (!onReadResource) return;
42+
if (!expanded) {
43+
setState((prev) => ({ ...prev, expanded: true, loading: true }));
44+
onReadResource(uri);
45+
setState((prev) => ({ ...prev, loading: false }));
46+
} else {
47+
setState((prev) => ({ ...prev, expanded: false }));
48+
}
49+
}, [expanded, onReadResource, uri]);
50+
51+
const handleKeyDown = useCallback(
52+
(e: React.KeyboardEvent) => {
53+
if ((e.key === "Enter" || e.key === " ") && onReadResource) {
54+
e.preventDefault();
55+
handleClick();
56+
}
57+
},
58+
[handleClick, onReadResource],
59+
);
60+
61+
return (
62+
<div className="text-sm text-foreground bg-secondary py-2 px-3 rounded">
63+
<div
64+
className="flex justify-between items-center cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 rounded"
65+
onClick={onReadResource ? handleClick : undefined}
66+
onKeyDown={onReadResource ? handleKeyDown : undefined}
67+
tabIndex={onReadResource ? 0 : -1}
68+
role="button"
69+
aria-expanded={expanded}
70+
aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
71+
>
72+
<div className="flex-1 min-w-0">
73+
<div className="flex items-start justify-between gap-2 mb-1">
74+
<span className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline px-1 py-0.5 break-all font-mono flex-1 min-w-0">
75+
{uri}
76+
</span>
77+
<div className="flex items-center gap-2 flex-shrink-0">
78+
{mimeType && (
79+
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
80+
{mimeType}
81+
</span>
82+
)}
83+
{onReadResource && (
84+
<span className="ml-2 flex-shrink-0" aria-hidden="true">
85+
{loading ? (
86+
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
87+
) : (
88+
<span>{expanded ? "▼" : "▶"}</span>
89+
)}
90+
</span>
91+
)}
92+
</div>
93+
</div>
94+
{name && (
95+
<div className="font-semibold text-sm text-gray-900 dark:text-gray-100 mb-1">
96+
{name}
97+
</div>
98+
)}
99+
{description && (
100+
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
101+
{description}
102+
</p>
103+
)}
104+
</div>
105+
</div>
106+
{expandedContent}
107+
</div>
108+
);
109+
},
110+
);
111+
112+
export default ResourceLinkView;

client/src/components/ToolResults.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import JsonView from "./JsonView";
2+
import ResourceLinkView from "./ResourceLinkView";
23
import {
34
CallToolResultSchema,
45
CompatibilityCallToolResult,
@@ -9,6 +10,8 @@ import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
910
interface ToolResultsProps {
1011
toolResult: CompatibilityCallToolResult | null;
1112
selectedTool: Tool | null;
13+
resourceContent: Record<string, string>;
14+
onReadResource?: (uri: string) => void;
1215
}
1316

1417
const checkContentCompatibility = (
@@ -61,7 +64,12 @@ const checkContentCompatibility = (
6164
}
6265
};
6366

64-
const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
67+
const ToolResults = ({
68+
toolResult,
69+
selectedTool,
70+
resourceContent,
71+
onReadResource,
72+
}: ToolResultsProps) => {
6573
if (!toolResult) return null;
6674

6775
if ("content" in toolResult) {
@@ -200,6 +208,16 @@ const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
200208
) : (
201209
<JsonView data={item.resource} />
202210
))}
211+
{item.type === "resource_link" && (
212+
<ResourceLinkView
213+
uri={item.uri}
214+
name={item.name}
215+
description={item.description}
216+
mimeType={item.mimeType}
217+
resourceContent={resourceContent[item.uri] || ""}
218+
onReadResource={onReadResource}
219+
/>
220+
)}
203221
</div>
204222
))}
205223
</div>

client/src/components/ToolsTab.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ const ToolsTab = ({
2828
setSelectedTool,
2929
toolResult,
3030
nextCursor,
31+
resourceContent,
32+
onReadResource,
3133
}: {
3234
tools: Tool[];
3335
listTools: () => void;
@@ -38,6 +40,8 @@ const ToolsTab = ({
3840
toolResult: CompatibilityCallToolResult | null;
3941
nextCursor: ListToolsResult["nextCursor"];
4042
error: string | null;
43+
resourceContent: Record<string, string>;
44+
onReadResource?: (uri: string) => void;
4145
}) => {
4246
const [params, setParams] = useState<Record<string, unknown>>({});
4347
const [isToolRunning, setIsToolRunning] = useState(false);
@@ -267,6 +271,8 @@ const ToolsTab = ({
267271
<ToolResults
268272
toolResult={toolResult}
269273
selectedTool={selectedTool}
274+
resourceContent={resourceContent}
275+
onReadResource={onReadResource}
270276
/>
271277
</div>
272278
) : (

client/src/components/__tests__/ToolsTab.test.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ describe("ToolsTab", () => {
5555
toolResult: null,
5656
nextCursor: "",
5757
error: null,
58+
resourceContent: {},
59+
onReadResource: jest.fn(),
5860
};
5961

6062
const renderToolsTab = (props = {}) => {
@@ -381,4 +383,103 @@ describe("ToolsTab", () => {
381383
).not.toBeInTheDocument();
382384
});
383385
});
386+
387+
describe("Resource Link Content Type", () => {
388+
it("should render resource_link content type and handle expansion", async () => {
389+
const mockOnReadResource = jest.fn();
390+
const resourceContent = {
391+
"test://static/resource/1": JSON.stringify({
392+
contents: [
393+
{
394+
uri: "test://static/resource/1",
395+
name: "Resource 1",
396+
mimeType: "text/plain",
397+
text: "Resource 1: This is a plaintext resource",
398+
},
399+
],
400+
}),
401+
};
402+
403+
const result = {
404+
content: [
405+
{
406+
type: "resource_link",
407+
uri: "test://static/resource/1",
408+
name: "Resource 1",
409+
description: "Resource 1: plaintext resource",
410+
mimeType: "text/plain",
411+
},
412+
{
413+
type: "resource_link",
414+
uri: "test://static/resource/2",
415+
name: "Resource 2",
416+
description: "Resource 2: binary blob resource",
417+
mimeType: "application/octet-stream",
418+
},
419+
{
420+
type: "resource_link",
421+
uri: "test://static/resource/3",
422+
name: "Resource 3",
423+
description: "Resource 3: plaintext resource",
424+
mimeType: "text/plain",
425+
},
426+
],
427+
};
428+
429+
renderToolsTab({
430+
selectedTool: mockTools[0],
431+
toolResult: result,
432+
resourceContent,
433+
onReadResource: mockOnReadResource,
434+
});
435+
436+
["1", "2", "3"].forEach((id) => {
437+
expect(
438+
screen.getByText(`test://static/resource/${id}`),
439+
).toBeInTheDocument();
440+
expect(screen.getByText(`Resource ${id}`)).toBeInTheDocument();
441+
});
442+
443+
expect(screen.getAllByText("text/plain")).toHaveLength(2);
444+
expect(screen.getByText("application/octet-stream")).toBeInTheDocument();
445+
446+
const expandButtons = screen.getAllByRole("button", {
447+
name: /expand resource/i,
448+
});
449+
expect(expandButtons).toHaveLength(3);
450+
expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
451+
452+
expandButtons.forEach((button) => {
453+
expect(button).toHaveAttribute("aria-expanded", "false");
454+
});
455+
456+
const resource1Button = screen.getByRole("button", {
457+
name: /expand resource test:\/\/static\/resource\/1/i,
458+
});
459+
460+
await act(async () => {
461+
fireEvent.click(resource1Button);
462+
});
463+
464+
expect(mockOnReadResource).toHaveBeenCalledWith(
465+
"test://static/resource/1",
466+
);
467+
expect(screen.getByText("Resource:")).toBeInTheDocument();
468+
expect(document.body).toHaveTextContent("contents:");
469+
expect(document.body).toHaveTextContent('uri:"test://static/resource/1"');
470+
expect(resource1Button).toHaveAttribute("aria-expanded", "true");
471+
472+
await act(async () => {
473+
fireEvent.click(resource1Button);
474+
});
475+
476+
expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
477+
expect(document.body).not.toHaveTextContent("contents:");
478+
expect(document.body).not.toHaveTextContent(
479+
'uri:"test://static/resource/1"',
480+
);
481+
expect(resource1Button).toHaveAttribute("aria-expanded", "false");
482+
expect(mockOnReadResource).toHaveBeenCalledTimes(1);
483+
});
484+
});
384485
});

0 commit comments

Comments
 (0)