Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ const App = () => {
const [resourceContentMap, setResourceContentMap] = useState<
Record<string, string>
>({});
const [resourceErrorMap, setResourceErrorMap] = useState<
Record<string, string>
>({});
const [fetchingResources, setFetchingResources] = useState<Set<string>>(
new Set(),
);
Expand Down Expand Up @@ -902,8 +905,16 @@ const App = () => {
setPromptContent(JSON.stringify(response, null, 2));
};

const readResource = async (uri: string) => {
if (fetchingResources.has(uri) || resourceContentMap[uri]) {
const readResource = async (
uri: string,
{ bypassCache = false }: { bypassCache?: boolean } = {},
) => {
if (fetchingResources.has(uri)) {
Comment on lines +908 to +912
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new cache-bypass and error-not-cached behavior in readResource is central to fixing #1120, but it’s currently only covered indirectly via ResourcesTab prop tests (it doesn’t assert that App actually re-fetches when cached, nor that errors aren’t cached). Consider adding an App-level unit/integration test that verifies: (1) cache hits sync resourceContent without calling makeRequest, (2) { bypassCache: true } forces a re-fetch even when cached, and (3) failed reads don’t populate resourceContentMap so a subsequent click retries.

Copilot uses AI. Check for mistakes.
return;
}
Comment on lines +908 to +914
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchingResources is React state, so fetchingResources.has(uri) can be stale if readResource is triggered twice before the state update is applied (e.g., double-click Refresh / rapid re-select). That can lead to duplicate resources/read requests for the same URI. Consider tracking in-flight URIs in a useRef (or similar) so the guard is synchronous and cannot race with state updates.

Copilot uses AI. Check for mistakes.

if (!bypassCache && resourceContentMap[uri]) {
setResourceContent(resourceContentMap[uri]);
Comment thread
olaservo marked this conversation as resolved.
return;
}

Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because resourceErrorMap[uri] is only cleared on success, any retry attempt will continue to have an error entry until the request completes. Since some UI paths render resourceError in preference to cached content, this can leave a stale error visible while the retry is in-flight. Consider clearing resourceErrorMap[uri] when starting a new read (after passing the cache check / before sending the request), not only when a read succeeds.

Suggested change
setResourceErrorMap((prev) => {
if (!hasOwn.call(prev, uri)) return prev;
const next = { ...prev };
delete next[uri];
return next;
});

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 01ea4cb — moved resourceErrorMap[uri] clearing from the success handler to right after the cache-hit check (before sending the request). During an in-flight retry, the UI now shows last-known-good content instead of the stale error.

Expand Down Expand Up @@ -931,13 +942,27 @@ const App = () => {
...prev,
[uri]: content,
}));
setResourceErrorMap((prev) => {
if (!(uri in prev)) return prev;
Comment thread
olaservo marked this conversation as resolved.
Outdated
const next = { ...prev };
delete next[uri];
return next;
});
} catch (error) {
console.error(`[App] Failed to read resource ${uri}:`, error);
const errorString = (error as Error).message ?? String(error);
setResourceContentMap((prev) => ({
setResourceContent(JSON.stringify({ error: errorString }));
Comment thread
olaservo marked this conversation as resolved.
Outdated
setResourceErrorMap((prev) => ({
...prev,
[uri]: JSON.stringify({ error: errorString }),
[uri]: errorString,
}));
Comment on lines 957 to 962
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

errorString uses nullish coalescing (??), so an Error with an empty message (e.g. new Error("")) will produce an empty string and render a blank error. Prefer a fallback that treats empty messages as missing (e.g. use || and/or include error.name).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in f879c0d — switched from ?? to || so an empty .message falls through to String(error), which always produces a non-empty representation.

if (bypassCache) {
setResourceContentMap((prev) => {
const next = { ...prev };
delete next[uri];
return next;
});
}
Comment thread
olaservo marked this conversation as resolved.
Outdated
} finally {
setFetchingResources((prev) => {
const next = new Set(prev);
Expand Down Expand Up @@ -1482,9 +1507,9 @@ const App = () => {
setResourceTemplates([]);
setNextResourceTemplateCursor(undefined);
}}
readResource={(uri) => {
readResource={(uri, options) => {
clearError("resources");
readResource(uri);
readResource(uri, options);
}}
selectedResource={selectedResource}
setSelectedResource={(resource) => {
Expand Down Expand Up @@ -1590,6 +1615,7 @@ const App = () => {
nextCursor={nextToolCursor}
error={errors.tools}
resourceContent={resourceContentMap}
resourceError={resourceErrorMap}
onReadResource={(uri: string) => {
clearError("resources");
readResource(uri);
Expand Down
28 changes: 22 additions & 6 deletions client/src/components/ResourceLinkView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface ResourceLinkViewProps {
description?: string;
mimeType?: string;
resourceContent: string;
resourceError?: string;
onReadResource?: (uri: string) => void;
}

Expand All @@ -17,25 +18,40 @@ const ResourceLinkView = memo(
description,
mimeType,
resourceContent,
resourceError,
onReadResource,
}: ResourceLinkViewProps) => {
const [{ expanded, loading }, setState] = useState({
expanded: false,
loading: false,
});

const expandedContent = useMemo(
() =>
expanded && resourceContent ? (
const expandedContent = useMemo(() => {
if (!expanded) return null;
if (resourceError) {
return (
<div className="mt-2">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-red-600">Error:</span>
</div>
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 p-2 rounded break-all">
{resourceError}
</div>
</div>
);
}
if (resourceContent) {
return (
Comment thread
olaservo marked this conversation as resolved.
<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],
);
);
}
return null;
}, [expanded, resourceContent, resourceError]);

const handleClick = useCallback(() => {
if (!onReadResource) return;
Expand Down
6 changes: 4 additions & 2 deletions client/src/components/ResourcesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const ResourcesTab = ({
clearResources: () => void;
listResourceTemplates: () => void;
clearResourceTemplates: () => void;
readResource: (uri: string) => void;
readResource: (uri: string, opts?: { bypassCache?: boolean }) => void;
selectedResource: Resource | null;
setSelectedResource: (resource: Resource | null) => void;
handleCompletion: (
Expand Down Expand Up @@ -229,7 +229,9 @@ const ResourcesTab = ({
<Button
variant="outline"
size="sm"
onClick={() => readResource(selectedResource.uri)}
onClick={() =>
readResource(selectedResource.uri, { bypassCache: true })
}
Comment thread
olaservo marked this conversation as resolved.
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
Expand Down
3 changes: 3 additions & 0 deletions client/src/components/ToolResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface ToolResultsProps {
toolResult: CompatibilityCallToolResult | null;
selectedTool: Tool | null;
resourceContent: Record<string, string>;
resourceError?: Record<string, string>;
onReadResource?: (uri: string) => void;
isPollingTask?: boolean;
}
Expand Down Expand Up @@ -63,6 +64,7 @@ const ToolResults = ({
toolResult,
selectedTool,
resourceContent,
resourceError,
onReadResource,
isPollingTask,
}: ToolResultsProps) => {
Expand Down Expand Up @@ -228,6 +230,7 @@ const ToolResults = ({
description={item.description}
mimeType={item.mimeType}
resourceContent={resourceContent[item.uri] || ""}
resourceError={resourceError?.[item.uri] || ""}
Comment thread
olaservo marked this conversation as resolved.
Outdated
onReadResource={onReadResource}
/>
)}
Expand Down
3 changes: 3 additions & 0 deletions client/src/components/ToolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ const ToolsTab = ({
nextCursor,
error,
resourceContent,
resourceError,
onReadResource,
serverSupportsTaskRequests,
}: {
Expand All @@ -195,6 +196,7 @@ const ToolsTab = ({
nextCursor: ListToolsResult["nextCursor"];
error: string | null;
resourceContent: Record<string, string>;
resourceError?: Record<string, string>;
onReadResource?: (uri: string) => void;
serverSupportsTaskRequests: boolean;
}) => {
Expand Down Expand Up @@ -884,6 +886,7 @@ const ToolsTab = ({
toolResult={toolResult}
selectedTool={selectedTool}
resourceContent={resourceContent}
resourceError={resourceError}
onReadResource={onReadResource}
isPollingTask={isPollingTask}
/>
Expand Down
28 changes: 28 additions & 0 deletions client/src/components/__tests__/ResourcesTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,4 +305,32 @@ describe("ResourcesTab - Template Query Parameters", () => {
),
).toBeInTheDocument();
});

it("should call readResource with bypassCache: true when Refresh is clicked", () => {
renderResourcesTab({
selectedResource: mockResource,
resourceContent: '{"users": []}',
});

const refreshButton = screen.getByText("Refresh");
fireEvent.click(refreshButton);

expect(mockReadResource).toHaveBeenCalledWith(mockResource.uri, {
bypassCache: true,
});
});

it("should call readResource without bypassCache when selecting a resource from the list", () => {
renderResourcesTab({
resources: [mockResource],
});

fireEvent.click(screen.getByText("Users Resource"));

expect(mockReadResource).toHaveBeenCalledWith(mockResource.uri);
expect(mockReadResource).not.toHaveBeenCalledWith(
mockResource.uri,
expect.objectContaining({ bypassCache: true }),
);
});
});
Loading