Skip to content

Commit 849e0e1

Browse files
committed
fix: display images returned by MCP tools in chat and forward embedded resource images to the model
1 parent 66120b4 commit 849e0e1

4 files changed

Lines changed: 238 additions & 1 deletion

File tree

src/core/tools/UseMcpToolTool.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,14 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
290290
return item.text
291291
}
292292
if (item.type === "resource") {
293-
const { blob: _, ...rest } = item.resource
293+
const { blob, ...rest } = item.resource
294+
if (blob && item.resource.mimeType?.startsWith("image")) {
295+
if (blob.startsWith("data:")) {
296+
images.push(blob)
297+
} else {
298+
images.push(`data:${item.resource.mimeType};base64,${blob}`)
299+
}
300+
}
294301
return JSON.stringify(rest, null, 2)
295302
}
296303
if (item.type === "image") {

src/core/tools/__tests__/useMcpToolTool.spec.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,126 @@ describe("useMcpToolTool", () => {
859859
])
860860
})
861861

862+
it("should extract image data from embedded resource blobs", async () => {
863+
const block: ToolUse<"use_mcp_tool"> = {
864+
type: "tool_use",
865+
name: "use_mcp_tool",
866+
params: {
867+
server_name: "godot-server",
868+
tool_name: "game_screenshot",
869+
arguments: "{}",
870+
},
871+
nativeArgs: {
872+
server_name: "godot-server",
873+
tool_name: "game_screenshot",
874+
arguments: {},
875+
},
876+
partial: false,
877+
}
878+
879+
mockAskApproval.mockResolvedValue(true)
880+
881+
const mockToolResult = {
882+
content: [
883+
{ type: "text", text: "Screenshot captured: 1152x648" },
884+
{
885+
type: "resource",
886+
resource: {
887+
uri: "godot://screenshot/latest",
888+
mimeType: "image/png",
889+
blob: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ",
890+
},
891+
},
892+
],
893+
isError: false,
894+
}
895+
896+
mockProviderRef.deref.mockReturnValue({
897+
getMcpHub: () => ({
898+
callTool: vi.fn().mockResolvedValue(mockToolResult),
899+
getAllServers: vi.fn().mockReturnValue([
900+
{
901+
name: "godot-server",
902+
tools: [{ name: "game_screenshot", description: "Capture screenshot" }],
903+
},
904+
]),
905+
}),
906+
postMessageToWebview: vi.fn(),
907+
})
908+
909+
await useMcpToolTool.handle(mockTask as Task, block, {
910+
askApproval: mockAskApproval,
911+
handleError: mockHandleError,
912+
pushToolResult: mockPushToolResult,
913+
})
914+
915+
expect(mockTask.say).toHaveBeenCalledWith(
916+
"mcp_server_response",
917+
expect.stringContaining("Screenshot captured: 1152x648"),
918+
["data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"],
919+
)
920+
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("with 1 image(s)"))
921+
})
922+
923+
it("should not extract non-image resource blobs", async () => {
924+
const block: ToolUse<"use_mcp_tool"> = {
925+
type: "tool_use",
926+
name: "use_mcp_tool",
927+
params: {
928+
server_name: "godot-server",
929+
tool_name: "read_scene",
930+
arguments: "{}",
931+
},
932+
nativeArgs: {
933+
server_name: "godot-server",
934+
tool_name: "read_scene",
935+
arguments: {},
936+
},
937+
partial: false,
938+
}
939+
940+
mockAskApproval.mockResolvedValue(true)
941+
942+
const mockToolResult = {
943+
content: [
944+
{
945+
type: "resource",
946+
resource: {
947+
uri: "godot://scene/main",
948+
mimeType: "text/plain",
949+
blob: "c2NlbmUgZGF0YQ==",
950+
},
951+
},
952+
],
953+
isError: false,
954+
}
955+
956+
mockProviderRef.deref.mockReturnValue({
957+
getMcpHub: () => ({
958+
callTool: vi.fn().mockResolvedValue(mockToolResult),
959+
getAllServers: vi.fn().mockReturnValue([
960+
{
961+
name: "godot-server",
962+
tools: [{ name: "read_scene", description: "Read scene" }],
963+
},
964+
]),
965+
}),
966+
postMessageToWebview: vi.fn(),
967+
})
968+
969+
await useMcpToolTool.handle(mockTask as Task, block, {
970+
askApproval: mockAskApproval,
971+
handleError: mockHandleError,
972+
pushToolResult: mockPushToolResult,
973+
})
974+
975+
expect(mockTask.say).toHaveBeenCalledWith(
976+
"mcp_server_response",
977+
expect.stringContaining("godot://scene/main"),
978+
[],
979+
)
980+
})
981+
862982
it("should handle multiple images in response", async () => {
863983
const block: ToolUse = {
864984
type: "tool_use",

webview-ui/src/components/chat/ChatRow.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1577,6 +1577,19 @@ export const ChatRowContent = ({
15771577
/>
15781578
)
15791579
}
1580+
case "mcp_server_response":
1581+
return (
1582+
<div style={{ paddingTop: 10 }}>
1583+
<Markdown markdown={message.text} partial={message.partial} />
1584+
{message.images && message.images.length > 0 && (
1585+
<div style={{ marginTop: "10px" }}>
1586+
{message.images.map((image, index) => (
1587+
<ImageBlock key={index} imageData={image} />
1588+
))}
1589+
</div>
1590+
)}
1591+
</div>
1592+
)
15801593
default:
15811594
return (
15821595
<>
@@ -1588,6 +1601,13 @@ export const ChatRowContent = ({
15881601
)}
15891602
<div style={{ paddingTop: 10 }}>
15901603
<Markdown markdown={message.text} partial={message.partial} />
1604+
{message.images && message.images.length > 0 && (
1605+
<div style={{ marginTop: "10px" }}>
1606+
{message.images.map((image, index) => (
1607+
<ImageBlock key={index} imageData={image} />
1608+
))}
1609+
</div>
1610+
)}
15911611
</div>
15921612
</>
15931613
)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import React from "react"
2+
3+
import { render, screen } from "@/utils/test-utils"
4+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
5+
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
6+
import { ChatRowContent } from "../ChatRow"
7+
8+
// Mock i18n
9+
vi.mock("react-i18next", () => ({
10+
useTranslation: () => ({
11+
t: (key: string) => key,
12+
i18n: {
13+
exists: () => false,
14+
},
15+
}),
16+
Trans: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
17+
initReactI18next: { type: "3rdParty", init: () => {} },
18+
}))
19+
20+
const queryClient = new QueryClient()
21+
22+
function renderChatRow(message: any) {
23+
return render(
24+
<ExtensionStateContextProvider>
25+
<QueryClientProvider client={queryClient}>
26+
<ChatRowContent
27+
message={message}
28+
isExpanded={false}
29+
isLast={false}
30+
isStreaming={false}
31+
onToggleExpand={() => {}}
32+
onSuggestionClick={() => {}}
33+
onBatchFileResponse={() => {}}
34+
onFollowUpUnmount={() => {}}
35+
isFollowUpAnswered={false}
36+
/>
37+
</QueryClientProvider>
38+
</ExtensionStateContextProvider>,
39+
)
40+
}
41+
42+
describe("ChatRow - mcp_server_response", () => {
43+
it("renders images attached to the MCP server response", () => {
44+
const message: any = {
45+
type: "say",
46+
say: "mcp_server_response",
47+
ts: Date.now(),
48+
partial: false,
49+
text: "Screenshot captured: 1152x648",
50+
images: ["data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"],
51+
}
52+
53+
renderChatRow(message)
54+
55+
const img = screen.getByRole("img")
56+
expect(img).toHaveAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ")
57+
})
58+
59+
it("renders multiple images attached to the MCP server response", () => {
60+
const message: any = {
61+
type: "say",
62+
say: "mcp_server_response",
63+
ts: Date.now(),
64+
partial: false,
65+
text: "[2 image(s) received]",
66+
images: ["data:image/png;base64,image1data", "data:image/png;base64,image2data"],
67+
}
68+
69+
renderChatRow(message)
70+
71+
const imgs = screen.getAllByRole("img")
72+
expect(imgs).toHaveLength(2)
73+
expect(imgs[0]).toHaveAttribute("src", "data:image/png;base64,image1data")
74+
expect(imgs[1]).toHaveAttribute("src", "data:image/png;base64,image2data")
75+
})
76+
77+
it("renders only the text when the MCP server response has no images", () => {
78+
const message: any = {
79+
type: "say",
80+
say: "mcp_server_response",
81+
ts: Date.now(),
82+
partial: false,
83+
text: "Plain text result",
84+
}
85+
86+
renderChatRow(message)
87+
88+
expect(screen.queryByRole("img")).toBeNull()
89+
})
90+
})

0 commit comments

Comments
 (0)