Skip to content

feat: Add resource_link content type support to ToolResults component #564

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
15 changes: 14 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ const App = () => {
ResourceTemplate[]
>([]);
const [resourceContent, setResourceContent] = useState<string>("");
const [resourceContentMap, setResourceContentMap] = useState<
Record<string, string>
>({});
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [promptContent, setPromptContent] = useState<string>("");
const [tools, setTools] = useState<Tool[]>([]);
Expand Down Expand Up @@ -461,7 +464,12 @@ const App = () => {
ReadResourceResultSchema,
"resources",
);
setResourceContent(JSON.stringify(response, null, 2));
const content = JSON.stringify(response, null, 2);
setResourceContent(content);
setResourceContentMap((prev) => ({
...prev,
[uri]: content,
}));
};

const subscribeToResource = async (uri: string) => {
Expand Down Expand Up @@ -863,6 +871,11 @@ const App = () => {
toolResult={toolResult}
nextCursor={nextToolCursor}
error={errors.tools}
resourceContent={resourceContentMap}
onReadResource={(uri: string) => {
clearError("resources");
readResource(uri);
}}
/>
<ConsoleTab />
<PingTab
Expand Down
112 changes: 112 additions & 0 deletions client/src/components/ResourceLinkView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useState, useCallback, useMemo, memo } from "react";
import JsonView from "./JsonView";

interface ResourceLinkViewProps {
uri: string;
name?: string;
description?: string;
mimeType?: string;
resourceContent: string;
onReadResource?: (uri: string) => void;
}

const ResourceLinkView = memo(
({
uri,
name,
description,
mimeType,
resourceContent,
onReadResource,
}: ResourceLinkViewProps) => {
const [{ expanded, loading }, setState] = useState({
expanded: false,
loading: false,
});

const expandedContent = useMemo(
() =>
expanded && resourceContent ? (
<div className="mt-2">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-green-600">Resource:</span>
</div>
<JsonView data={resourceContent} className="bg-background" />
</div>
) : null,
[expanded, resourceContent],
);

const handleClick = useCallback(() => {
if (!onReadResource) return;
if (!expanded) {
setState((prev) => ({ ...prev, expanded: true, loading: true }));
onReadResource(uri);
setState((prev) => ({ ...prev, loading: false }));
} else {
setState((prev) => ({ ...prev, expanded: false }));
}
}, [expanded, onReadResource, uri]);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if ((e.key === "Enter" || e.key === " ") && onReadResource) {
e.preventDefault();
handleClick();
}
},
[handleClick, onReadResource],
);

return (
<div className="text-sm text-foreground bg-secondary py-2 px-3 rounded">
<div
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"
onClick={onReadResource ? handleClick : undefined}
onKeyDown={onReadResource ? handleKeyDown : undefined}
tabIndex={onReadResource ? 0 : -1}
role="button"
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<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">
{uri}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{mimeType && (
<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">
{mimeType}
</span>
)}
{onReadResource && (
<span className="ml-2 flex-shrink-0" aria-hidden="true">
{loading ? (
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
) : (
<span>{expanded ? "▼" : "▶"}</span>
)}
</span>
)}
</div>
</div>
{name && (
<div className="font-semibold text-sm text-gray-900 dark:text-gray-100 mb-1">
{name}
</div>
)}
{description && (
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{description}
</p>
)}
</div>
</div>
{expandedContent}
</div>
);
},
);

export default ResourceLinkView;
20 changes: 19 additions & 1 deletion client/src/components/ToolResults.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import JsonView from "./JsonView";
import ResourceLinkView from "./ResourceLinkView";
import {
CallToolResultSchema,
CompatibilityCallToolResult,
Expand All @@ -9,6 +10,8 @@ import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
interface ToolResultsProps {
toolResult: CompatibilityCallToolResult | null;
selectedTool: Tool | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
}

const checkContentCompatibility = (
Expand Down Expand Up @@ -61,7 +64,12 @@ const checkContentCompatibility = (
}
};

const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
const ToolResults = ({
toolResult,
selectedTool,
resourceContent,
onReadResource,
}: ToolResultsProps) => {
if (!toolResult) return null;

if ("content" in toolResult) {
Expand Down Expand Up @@ -200,6 +208,16 @@ const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
) : (
<JsonView data={item.resource} />
))}
{item.type === "resource_link" && (
<ResourceLinkView
uri={item.uri}
name={item.name}
description={item.description}
mimeType={item.mimeType}
resourceContent={resourceContent[item.uri] || ""}
onReadResource={onReadResource}
/>
)}
</div>
))}
</div>
Expand Down
6 changes: 6 additions & 0 deletions client/src/components/ToolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const ToolsTab = ({
setSelectedTool,
toolResult,
nextCursor,
resourceContent,
onReadResource,
}: {
tools: Tool[];
listTools: () => void;
Expand All @@ -38,6 +40,8 @@ const ToolsTab = ({
toolResult: CompatibilityCallToolResult | null;
nextCursor: ListToolsResult["nextCursor"];
error: string | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = useState(false);
Expand Down Expand Up @@ -267,6 +271,8 @@ const ToolsTab = ({
<ToolResults
toolResult={toolResult}
selectedTool={selectedTool}
resourceContent={resourceContent}
onReadResource={onReadResource}
/>
</div>
) : (
Expand Down
101 changes: 101 additions & 0 deletions client/src/components/__tests__/ToolsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ describe("ToolsTab", () => {
toolResult: null,
nextCursor: "",
error: null,
resourceContent: {},
onReadResource: jest.fn(),
};

const renderToolsTab = (props = {}) => {
Expand Down Expand Up @@ -381,4 +383,103 @@ describe("ToolsTab", () => {
).not.toBeInTheDocument();
});
});

describe("Resource Link Content Type", () => {
it("should render resource_link content type and handle expansion", async () => {
const mockOnReadResource = jest.fn();
const resourceContent = {
"test://static/resource/1": JSON.stringify({
contents: [
{
uri: "test://static/resource/1",
name: "Resource 1",
mimeType: "text/plain",
text: "Resource 1: This is a plaintext resource",
},
],
}),
};

const result = {
content: [
{
type: "resource_link",
uri: "test://static/resource/1",
name: "Resource 1",
description: "Resource 1: plaintext resource",
mimeType: "text/plain",
},
{
type: "resource_link",
uri: "test://static/resource/2",
name: "Resource 2",
description: "Resource 2: binary blob resource",
mimeType: "application/octet-stream",
},
{
type: "resource_link",
uri: "test://static/resource/3",
name: "Resource 3",
description: "Resource 3: plaintext resource",
mimeType: "text/plain",
},
],
};

renderToolsTab({
selectedTool: mockTools[0],
toolResult: result,
resourceContent,
onReadResource: mockOnReadResource,
});

["1", "2", "3"].forEach((id) => {
expect(
screen.getByText(`test://static/resource/${id}`),
).toBeInTheDocument();
expect(screen.getByText(`Resource ${id}`)).toBeInTheDocument();
});

expect(screen.getAllByText("text/plain")).toHaveLength(2);
expect(screen.getByText("application/octet-stream")).toBeInTheDocument();

const expandButtons = screen.getAllByRole("button", {
name: /expand resource/i,
});
expect(expandButtons).toHaveLength(3);
expect(screen.queryByText("Resource:")).not.toBeInTheDocument();

expandButtons.forEach((button) => {
expect(button).toHaveAttribute("aria-expanded", "false");
});

const resource1Button = screen.getByRole("button", {
name: /expand resource test:\/\/static\/resource\/1/i,
});

await act(async () => {
fireEvent.click(resource1Button);
});

expect(mockOnReadResource).toHaveBeenCalledWith(
"test://static/resource/1",
);
expect(screen.getByText("Resource:")).toBeInTheDocument();
expect(document.body).toHaveTextContent("contents:");
expect(document.body).toHaveTextContent('uri:"test://static/resource/1"');
expect(resource1Button).toHaveAttribute("aria-expanded", "true");

await act(async () => {
fireEvent.click(resource1Button);
});

expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
expect(document.body).not.toHaveTextContent("contents:");
expect(document.body).not.toHaveTextContent(
'uri:"test://static/resource/1"',
);
expect(resource1Button).toHaveAttribute("aria-expanded", "false");
expect(mockOnReadResource).toHaveBeenCalledTimes(1);
});
});
});