From f916c413f2aab1aecf2b0cf2391a409b124465be Mon Sep 17 00:00:00 2001 From: Shivang Agrawal Date: Fri, 27 Jun 2025 13:47:42 +0530 Subject: [PATCH 01/10] feat: enhance MCP tool response handling to support images **Changes:** - Updated `processToolContent` to return both text and images from tool results. - Modified `executeToolAndProcessResult` to handle and pass images to the response. - Adjusted `combineCommandSequences` to preserve images from MCP server responses. - Updated UI components to display images alongside text responses. **Testing:** - Added tests to verify correct handling of tool results with text and images. - Ensured that image-only responses are processed correctly. **Files Modified:** - `src/core/prompts/responses.ts` - `src/core/tools/useMcpToolTool.ts` - `src/shared/combineCommandSequences.ts` - `webview-ui/src/components/chat/McpExecution.tsx` - `webview-ui/src/components/chat/ChatRow.tsx` - Test files for MCP tool functionality. --- src/core/prompts/responses.ts | 12 +- .../tools/__tests__/useMcpToolTool.spec.ts | 110 +++++++++++++++++- src/core/tools/useMcpToolTool.ts | 53 ++++++--- .../__tests__/combineCommandSequences.spec.ts | 42 +++++++ src/shared/combineCommandSequences.ts | 20 +++- webview-ui/src/components/chat/ChatRow.tsx | 1 + .../src/components/chat/McpExecution.tsx | 50 +++++--- 7 files changed, 248 insertions(+), 40 deletions(-) diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index fd51b18feda..64dc392e94f 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -87,10 +87,16 @@ Otherwise, if you have not completed the task and do not need additional informa images?: string[], ): string | Array => { if (images && images.length > 0) { - const textBlock: Anthropic.TextBlockParam = { type: "text", text } const imageBlocks: Anthropic.ImageBlockParam[] = formatImagesIntoBlocks(images) - // Placing images after text leads to better results - return [textBlock, ...imageBlocks] + + if (text.trim()) { + const textBlock: Anthropic.TextBlockParam = { type: "text", text } + // Placing images after text leads to better results + return [textBlock, ...imageBlocks] + } else { + // For image-only responses, return only image blocks + return imageBlocks + } } else { return text } diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 8738e059e55..82b3c1fdeb0 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -7,7 +7,12 @@ import { ToolUse } from "../../../shared/tools" // Mock dependencies vi.mock("../../prompts/responses", () => ({ formatResponse: { - toolResult: vi.fn((result: string) => `Tool result: ${result}`), + toolResult: vi.fn((result: string, images?: string[]) => { + if (images && images.length > 0) { + return `Tool result: ${result} (with ${images.length} images)` + } + return `Tool result: ${result}` + }), toolError: vi.fn((error: string) => `Tool error: ${error}`), invalidMcpToolArgumentError: vi.fn((server: string, tool: string) => `Invalid args for ${server}:${tool}`), unknownMcpToolError: vi.fn((server: string, tool: string, availableTools: string[]) => { @@ -223,10 +228,111 @@ describe("useMcpToolTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockAskApproval).toHaveBeenCalled() expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") - expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully", []) expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: Tool executed successfully") }) + it("should handle tool result with text and images", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Generated image:" }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + }) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Generated image:", [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + ]) + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: Generated image: (with 1 images)") + }) + + it("should handle tool result with only images (no text)", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + }) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "", [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + ]) + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: (with 1 images)") + }) + it("should handle user rejection", async () => { const block: ToolUse = { type: "tool_use", diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/useMcpToolTool.ts index 41697ab979b..d55b8ef5902 100644 --- a/src/core/tools/useMcpToolTool.ts +++ b/src/core/tools/useMcpToolTool.ts @@ -195,24 +195,39 @@ async function sendExecutionStatus(cline: Task, status: McpExecutionStatus): Pro }) } -function processToolContent(toolResult: any): string { +function processToolContent(toolResult: any): { text: string; images: string[] } { if (!toolResult?.content || toolResult.content.length === 0) { - return "" + return { text: "", images: [] } } - return toolResult.content - .map((item: any) => { - if (item.type === "text") { - return item.text + const textParts: string[] = [] + const images: string[] = [] + + toolResult.content.forEach((item: any) => { + if (item.type === "text") { + textParts.push(item.text) + } else if (item.type === "image") { + if (item.data && item.mimeType) { + const validImageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"] + if (validImageTypes.includes(item.mimeType)) { + const dataUrl = `data:${item.mimeType};base64,${item.data}` + images.push(dataUrl) + } else { + console.warn(`Unsupported image MIME type: ${item.mimeType}`) + } + } else { + console.warn("Invalid MCP ImageContent: missing data or mimeType") } - if (item.type === "resource") { - const { blob: _, ...rest } = item.resource - return JSON.stringify(rest, null, 2) - } - return "" - }) - .filter(Boolean) - .join("\n\n") + } else if (item.type === "resource") { + const { blob: _, ...rest } = item.resource + textParts.push(JSON.stringify(rest, null, 2)) + } + }) + + return { + text: textParts.filter(Boolean).join("\n\n"), + images, + } } async function executeToolAndProcessResult( @@ -236,11 +251,13 @@ async function executeToolAndProcessResult( const toolResult = await cline.providerRef.deref()?.getMcpHub()?.callTool(serverName, toolName, parsedArguments) let toolResultPretty = "(No response)" + let images: string[] = [] if (toolResult) { - const outputText = processToolContent(toolResult) + const { text: outputText, images: outputImages } = processToolContent(toolResult) + images = outputImages - if (outputText) { + if (outputText || images.length > 0) { await sendExecutionStatus(cline, { executionId, status: "output", @@ -266,8 +283,8 @@ async function executeToolAndProcessResult( }) } - await cline.say("mcp_server_response", toolResultPretty) - pushToolResult(formatResponse.toolResult(toolResultPretty)) + await cline.say("mcp_server_response", toolResultPretty, images) + pushToolResult(formatResponse.toolResult(toolResultPretty, images)) } export async function useMcpToolTool( diff --git a/src/shared/__tests__/combineCommandSequences.spec.ts b/src/shared/__tests__/combineCommandSequences.spec.ts index 86bed15d205..1f0add08e79 100644 --- a/src/shared/__tests__/combineCommandSequences.spec.ts +++ b/src/shared/__tests__/combineCommandSequences.spec.ts @@ -89,6 +89,48 @@ describe("combineCommandSequences", () => { }) }) + it("should preserve images from mcp_server_response messages", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "use_mcp_server", + text: JSON.stringify({ + serverName: "test-server", + toolName: "test-tool", + arguments: { param: "value" }, + }), + ts: 1625097600000, + }, + { + type: "say", + say: "mcp_server_response", + text: "Generated 1 image", + images: [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + ], + ts: 1625097601000, + }, + ] + + const result = combineCommandSequences(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + type: "ask", + ask: "use_mcp_server", + text: JSON.stringify({ + serverName: "test-server", + toolName: "test-tool", + arguments: { param: "value" }, + response: "Generated 1 image", + }), + images: [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + ], + ts: 1625097600000, + }) + }) + it("should handle multiple MCP server requests", () => { const messages: ClineMessage[] = [ { diff --git a/src/shared/combineCommandSequences.ts b/src/shared/combineCommandSequences.ts index 56b97a368e5..cd1493409ec 100644 --- a/src/shared/combineCommandSequences.ts +++ b/src/shared/combineCommandSequences.ts @@ -38,11 +38,16 @@ export function combineCommandSequences(messages: ClineMessage[]): ClineMessage[ if (msg.type === "ask" && msg.ask === "use_mcp_server") { // Look ahead for MCP responses let responses: string[] = [] + let allImages: string[] = [] let j = i + 1 while (j < messages.length) { if (messages[j].say === "mcp_server_response") { responses.push(messages[j].text || "") + // Collect images from MCP server responses + if (messages[j].images && Array.isArray(messages[j].images) && messages[j].images!.length > 0) { + allImages.push(...messages[j].images!) + } processedIndices.add(j) j++ } else if (messages[j].type === "ask" && messages[j].ask === "use_mcp_server") { @@ -57,13 +62,22 @@ export function combineCommandSequences(messages: ClineMessage[]): ClineMessage[ // Parse the JSON from the message text const jsonObj = safeJsonParse(msg.text || "{}", {}) - // Add the response to the JSON object - jsonObj.response = responses.join("\n") + // Only add non-empty responses + const nonEmptyResponses = responses.filter((response) => response.trim()) + if (nonEmptyResponses.length > 0) { + jsonObj.response = nonEmptyResponses.join("\n") + } // Stringify the updated JSON object const combinedText = JSON.stringify(jsonObj) - combinedMessages.set(msg.ts, { ...msg, text: combinedText }) + // Preserve images in the combined message + const combinedMessage = { ...msg, text: combinedText } + if (allImages.length > 0) { + combinedMessage.images = allImages + } + + combinedMessages.set(msg.ts, combinedMessage) } else { // If there's no response, just keep the original message combinedMessages.set(msg.ts, { ...msg }) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 23ec50af37d..96940b67c77 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1500,6 +1500,7 @@ export const ChatRowContent = ({ server={server} useMcpServer={useMcpServer} alwaysAllowMcp={alwaysAllowMcp} + images={message.images} /> )} diff --git a/webview-ui/src/components/chat/McpExecution.tsx b/webview-ui/src/components/chat/McpExecution.tsx index a96f368a17e..5ffdbe03f0e 100644 --- a/webview-ui/src/components/chat/McpExecution.tsx +++ b/webview-ui/src/components/chat/McpExecution.tsx @@ -10,6 +10,7 @@ import { cn } from "@src/lib/utils" import { Button } from "@src/components/ui" import CodeBlock from "../common/CodeBlock" import McpToolRow from "../mcp/McpToolRow" +import Thumbnails from "../common/Thumbnails" import { Markdown } from "./Markdown" interface McpExecutionProps { @@ -28,6 +29,7 @@ interface McpExecutionProps { } useMcpServer?: ClineAskUseMcpServer alwaysAllowMcp?: boolean + images?: string[] } export const McpExecution = ({ @@ -39,6 +41,7 @@ export const McpExecution = ({ server, useMcpServer, alwaysAllowMcp = false, + images, }: McpExecutionProps) => { const { t } = useTranslation("mcp") @@ -212,15 +215,23 @@ export const McpExecution = ({ )} )} - {responseText && responseText.length > 0 && ( - - )} + {(responseText && responseText.length > 0) || (images && images.length > 0) ? ( +
+ {images && images.length > 0 && ( +
+ + {images.length} +
+ )} + +
+ ) : null} @@ -280,6 +291,7 @@ export const McpExecution = ({ isJson={responseIsJson} hasArguments={!!(isArguments || useMcpServer?.arguments || argumentsText)} isPartial={status ? status.status !== "completed" : false} + images={images} /> @@ -294,15 +306,17 @@ const ResponseContainerInternal = ({ isJson, hasArguments, isPartial = false, + images, }: { isExpanded: boolean response: string isJson: boolean hasArguments?: boolean isPartial?: boolean + images?: string[] }) => { // Only render content when expanded to prevent performance issues with large responses - if (!isExpanded || response.length === 0) { + if (!isExpanded || (response.length === 0 && (!images || images.length === 0))) { return (
- {isJson ? ( - - ) : ( - + {images && images.length > 0 && ( +
+ +
)} + {shouldShowText && + (isJson ? ( + + ) : ( + + ))}
) } From 3a942bd104bb782f558b60ed8f40fac6ef8ed4d7 Mon Sep 17 00:00:00 2001 From: Shivang Agrawal Date: Sat, 28 Jun 2025 21:30:41 +0530 Subject: [PATCH 02/10] feat: enhance image handling in MCP tool processing **Changes:** - Improved validation for base64 image data in `processToolContent` to handle invalid and non-string data gracefully. - Added error handling to log warnings for corrupted images without interrupting processing. - Updated tests to verify correct behavior when encountering invalid base64 data and non-string inputs. **Files Modified:** - `src/core/tools/useMcpToolTool.ts` - `src/core/tools/__tests__/useMcpToolTool.spec.ts` - `webview-ui/src/components/common/Thumbnails.tsx` --- .../tools/__tests__/useMcpToolTool.spec.ts | 129 ++++++++++++++++++ src/core/tools/useMcpToolTool.ts | 24 +++- .../src/components/common/Thumbnails.tsx | 57 ++++++-- 3 files changed, 195 insertions(+), 15 deletions(-) diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 82b3c1fdeb0..44d3f8f8f90 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -333,6 +333,135 @@ describe("useMcpToolTool", () => { expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: (with 1 images)") }) + it("should handle corrupted base64 image data gracefully", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Generated content with images:" }, + { + type: "image", + data: "invalid@base64@data", // Invalid base64 characters + mimeType: "image/png", + }, + { + type: "image", + data: "", // Empty base64 data + mimeType: "image/png", + }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", // Valid base64 + mimeType: "image/png", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + }) + + // Spy on console.warn to verify error logging + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should continue processing despite corrupted images + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") + + // Should only include the valid image, not the corrupted ones + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Generated content with images:", [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + ]) + expect(mockPushToolResult).toHaveBeenCalledWith( + "Tool result: Generated content with images: (with 1 images)", + ) + + // Should log warnings for corrupted images + expect(consoleSpy).toHaveBeenCalledWith("Invalid MCP ImageContent: base64 data contains invalid characters") + expect(consoleSpy).toHaveBeenCalledWith("Invalid MCP ImageContent: base64 data is not a valid string") + + consoleSpy.mockRestore() + }) + + it("should handle non-string base64 data", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Some text" }, + { + type: "image", + data: 12345, // Non-string data + mimeType: "image/png", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should process text content normally + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Some text", []) + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: Some text") + + // Should log warning for invalid data type + expect(consoleSpy).toHaveBeenCalledWith("Invalid MCP ImageContent: base64 data is not a valid string") + + consoleSpy.mockRestore() + }) + it("should handle user rejection", async () => { const block: ToolUse = { type: "tool_use", diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/useMcpToolTool.ts index d55b8ef5902..dd1129d43ad 100644 --- a/src/core/tools/useMcpToolTool.ts +++ b/src/core/tools/useMcpToolTool.ts @@ -207,11 +207,29 @@ function processToolContent(toolResult: any): { text: string; images: string[] } if (item.type === "text") { textParts.push(item.text) } else if (item.type === "image") { - if (item.data && item.mimeType) { + if (item.mimeType && item.data !== undefined && item.data !== null) { const validImageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"] if (validImageTypes.includes(item.mimeType)) { - const dataUrl = `data:${item.mimeType};base64,${item.data}` - images.push(dataUrl) + try { + // Validate base64 data before constructing data URL + if (typeof item.data !== "string" || item.data.trim() === "") { + console.warn("Invalid MCP ImageContent: base64 data is not a valid string") + return + } + + // Basic validation for base64 format + const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/ + if (!base64Regex.test(item.data.replace(/\s/g, ""))) { + console.warn("Invalid MCP ImageContent: base64 data contains invalid characters") + return + } + + const dataUrl = `data:${item.mimeType};base64,${item.data}` + images.push(dataUrl) + } catch (error) { + console.warn("Failed to process MCP image content:", error) + // Continue processing other content instead of failing entirely + } } else { console.warn(`Unsupported image MIME type: ${item.mimeType}`) } diff --git a/webview-ui/src/components/common/Thumbnails.tsx b/webview-ui/src/components/common/Thumbnails.tsx index d0db36d5612..0b8d88bce10 100644 --- a/webview-ui/src/components/common/Thumbnails.tsx +++ b/webview-ui/src/components/common/Thumbnails.tsx @@ -11,6 +11,7 @@ interface ThumbnailsProps { const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProps) => { const [hoveredIndex, setHoveredIndex] = useState(null) + const [failedImages, setFailedImages] = useState>(new Set()) const containerRef = useRef(null) const { width } = useWindowSize() @@ -24,12 +25,19 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp onHeightChange?.(height) } setHoveredIndex(null) + // Reset failed images when images change + setFailedImages(new Set()) }, [images, width, onHeightChange]) const handleDelete = (index: number) => { setImages?.((prevImages) => prevImages.filter((_, i) => i !== index)) } + const handleImageError = (index: number) => { + setFailedImages((prev) => new Set(prev).add(index)) + console.warn(`Failed to load image at index ${index}`) + } + const isDeletable = setImages !== undefined const handleImageClick = (image: string) => { @@ -53,18 +61,43 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp style={{ position: "relative" }} onMouseEnter={() => setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(null)}> - {`Thumbnail handleImageClick(image)} - /> + {failedImages.has(index) ? ( +
handleImageClick(image)} + title="Failed to load image"> + +
+ ) : ( + {`Thumbnail handleImageClick(image)} + onError={() => handleImageError(index)} + /> + )} {isDeletable && hoveredIndex === index && (
handleDelete(index)} From 31817eb0918c28860c77eeb9275699665720ef0c Mon Sep 17 00:00:00 2001 From: Shivang Agrawal Date: Sun, 29 Jun 2025 19:49:16 +0530 Subject: [PATCH 03/10] feat: add MCP image handling configuration options **Changes:** - Introduced `mcpMaxImagesPerResponse` and `mcpMaxImageSizeMB` settings to control the maximum number of images and their size in MCP tool responses. - Updated `processToolContent` to enforce these limits, logging warnings when they are exceeded. - Enhanced UI components to allow users to configure these settings. - Added tests to verify correct behavior under various image limits and sizes. **Files Modified:** - `packages/types/src/global-settings.ts` - `src/core/tools/useMcpToolTool.ts` - `src/core/webview/ClineProvider.ts` - `src/core/webview/webviewMessageHandler.ts` - `src/shared/ExtensionMessage.ts` - `src/shared/WebviewMessage.ts` - `webview-ui/src/components/mcp/McpView.tsx` - `webview-ui/src/context/ExtensionStateContext.tsx` - `webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx` - Test files for MCP tool functionality. --- packages/types/src/global-settings.ts | 2 + .../tools/__tests__/useMcpToolTool.spec.ts | 293 ++++++++++++++++++ src/core/tools/useMcpToolTool.ts | 35 ++- src/core/webview/ClineProvider.ts | 6 + src/core/webview/webviewMessageHandler.ts | 8 + src/shared/ExtensionMessage.ts | 2 + src/shared/WebviewMessage.ts | 2 + webview-ui/src/components/mcp/McpView.tsx | 51 +++ .../src/context/ExtensionStateContext.tsx | 11 +- .../__tests__/ExtensionStateContext.spec.tsx | 2 + 10 files changed, 409 insertions(+), 3 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 7e79855f7e1..24643fdd4f9 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -138,6 +138,8 @@ export const globalSettingsSchema = z.object({ mcpEnabled: z.boolean().optional(), enableMcpServerCreation: z.boolean().optional(), + mcpMaxImagesPerResponse: z.number().optional(), + mcpMaxImageSizeMB: z.number().optional(), mode: z.string().optional(), modeApiConfigs: z.record(z.string(), z.string()).optional(), diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 44d3f8f8f90..513ab461424 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -62,6 +62,10 @@ describe("useMcpToolTool", () => { getAllServers: vi.fn().mockReturnValue([]), }), postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), }), } @@ -214,6 +218,10 @@ describe("useMcpToolTool", () => { callTool: vi.fn().mockResolvedValue(mockToolResult), }), postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), }) await useMcpToolTool( @@ -263,6 +271,10 @@ describe("useMcpToolTool", () => { callTool: vi.fn().mockResolvedValue(mockToolResult), }), postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), }) await useMcpToolTool( @@ -313,6 +325,10 @@ describe("useMcpToolTool", () => { callTool: vi.fn().mockResolvedValue(mockToolResult), }), postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), }) await useMcpToolTool( @@ -374,6 +390,10 @@ describe("useMcpToolTool", () => { callTool: vi.fn().mockResolvedValue(mockToolResult), }), postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), }) // Spy on console.warn to verify error logging @@ -439,6 +459,10 @@ describe("useMcpToolTool", () => { callTool: vi.fn().mockResolvedValue(mockToolResult), }), postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), }) const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) @@ -462,6 +486,275 @@ describe("useMcpToolTool", () => { consoleSpy.mockRestore() }) + it("should limit the number of images to prevent performance issues", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + // Create more than 20 images (the current limit) + const imageContent = Array.from({ length: 25 }, (_, i) => ({ + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", + })) + + const mockToolResult = { + content: [{ type: "text", text: "Generated many images:" }, ...imageContent], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should only process first 20 images + expect(mockTask.say).toHaveBeenCalledWith( + "mcp_server_response", + "Generated many images:", + expect.arrayContaining([expect.stringMatching(/^data:image\/png;base64,/)]), + ) + + // Check that exactly 20 images were processed + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(20) + + // Should log warning about exceeding limit + expect(consoleSpy).toHaveBeenCalledWith( + "MCP response contains more than 20 images. Additional images will be ignored to prevent performance issues.", + ) + + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: Generated many images: (with 20 images)") + + consoleSpy.mockRestore() + }) + + it("should handle exactly the maximum number of images without warning", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + // Create exactly 20 images (the current limit) + const imageContent = Array.from({ length: 20 }, (_, i) => ({ + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", + })) + + const mockToolResult = { + content: [{ type: "text", text: "Generated exactly 20 images:" }, ...imageContent], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should process all 20 images + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(20) + + // Should NOT log warning about exceeding limit + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining("MCP response contains more than 20 images"), + ) + + expect(mockPushToolResult).toHaveBeenCalledWith( + "Tool result: Generated exactly 20 images: (with 20 images)", + ) + + consoleSpy.mockRestore() + }) + + it("should respect custom maxImagesPerResponse setting", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + // Create 10 images (more than custom limit of 5) + const imageContent = Array.from({ length: 10 }, () => ({ + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", + })) + + const mockToolResult = { + content: [{ type: "text", text: "Generated many images:" }, ...imageContent], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 5, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should only process first 5 images (custom limit) + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(5) + + // Should log warning about exceeding custom limit + expect(consoleSpy).toHaveBeenCalledWith( + "MCP response contains more than 5 images. Additional images will be ignored to prevent performance issues.", + ) + + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: Generated many images: (with 5 images)") + + consoleSpy.mockRestore() + }) + + it("should reject images that exceed size limit", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + // Create a large base64 string (approximately 2MB when decoded) + const largeBase64 = "A".repeat((2 * 1024 * 1024 * 4) / 3) // Base64 is ~33% larger than original + const smallBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU" + + const mockToolResult = { + content: [ + { type: "text", text: "Generated images with different sizes:" }, + { + type: "image", + data: largeBase64, // This should be rejected (too large) + mimeType: "image/png", + }, + { + type: "image", + data: smallBase64, // This should be accepted + mimeType: "image/png", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 1, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should only include the small image, not the large one + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(1) + expect(sayCall[2][0]).toContain(smallBase64) + + // Should log warning about size exceeding limit + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/MCP image exceeds size limit: .* > 1MB\. Image will be ignored\./), + ) + + expect(mockPushToolResult).toHaveBeenCalledWith( + "Tool result: Generated images with different sizes: (with 1 images)", + ) + + consoleSpy.mockRestore() + }) + it("should handle user rejection", async () => { const block: ToolUse = { type: "tool_use", diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/useMcpToolTool.ts index dd1129d43ad..c2f232bb68c 100644 --- a/src/core/tools/useMcpToolTool.ts +++ b/src/core/tools/useMcpToolTool.ts @@ -195,7 +195,16 @@ async function sendExecutionStatus(cline: Task, status: McpExecutionStatus): Pro }) } -function processToolContent(toolResult: any): { text: string; images: string[] } { +/** + * Calculate the approximate size of a base64 encoded image in MB + */ +function calculateImageSizeMB(base64Data: string): number { + // Base64 encoding increases size by ~33%, so actual bytes = base64Length * 0.75 + const sizeInBytes = base64Data.length * 0.75 + return sizeInBytes / (1024 * 1024) // Convert to MB +} + +async function processToolContent(toolResult: any, cline: Task): Promise<{ text: string; images: string[] }> { if (!toolResult?.content || toolResult.content.length === 0) { return { text: "", images: [] } } @@ -203,10 +212,23 @@ function processToolContent(toolResult: any): { text: string; images: string[] } const textParts: string[] = [] const images: string[] = [] + // Get MCP settings from the extension's global state + const state = await cline.providerRef.deref()?.getState() + const maxImagesPerResponse = state?.mcpMaxImagesPerResponse ?? 20 + const maxImageSizeMB = state?.mcpMaxImageSizeMB ?? 10 + toolResult.content.forEach((item: any) => { if (item.type === "text") { textParts.push(item.text) } else if (item.type === "image") { + // Check if we've exceeded the maximum number of images + if (images.length >= maxImagesPerResponse) { + console.warn( + `MCP response contains more than ${maxImagesPerResponse} images. Additional images will be ignored to prevent performance issues.`, + ) + return // Skip processing additional images + } + if (item.mimeType && item.data !== undefined && item.data !== null) { const validImageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"] if (validImageTypes.includes(item.mimeType)) { @@ -224,6 +246,15 @@ function processToolContent(toolResult: any): { text: string; images: string[] } return } + // Check image size + const imageSizeMB = calculateImageSizeMB(item.data) + if (imageSizeMB > maxImageSizeMB) { + console.warn( + `MCP image exceeds size limit: ${imageSizeMB.toFixed(2)}MB > ${maxImageSizeMB}MB. Image will be ignored.`, + ) + return + } + const dataUrl = `data:${item.mimeType};base64,${item.data}` images.push(dataUrl) } catch (error) { @@ -272,7 +303,7 @@ async function executeToolAndProcessResult( let images: string[] = [] if (toolResult) { - const { text: outputText, images: outputImages } = processToolContent(toolResult) + const { text: outputText, images: outputImages } = await processToolContent(toolResult, cline) images = outputImages if (outputText || images.length > 0) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f198fad8b2b..daaa17ca78b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1738,6 +1738,8 @@ export class ClineProvider fuzzyMatchThreshold, mcpEnabled, enableMcpServerCreation, + mcpMaxImagesPerResponse, + mcpMaxImageSizeMB, alwaysApproveResubmit, requestDelaySeconds, currentApiConfigName, @@ -1853,6 +1855,8 @@ export class ClineProvider fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0, mcpEnabled: mcpEnabled ?? true, enableMcpServerCreation: enableMcpServerCreation ?? true, + mcpMaxImagesPerResponse: mcpMaxImagesPerResponse ?? 20, + mcpMaxImageSizeMB: mcpMaxImageSizeMB ?? 10, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 10, currentApiConfigName: currentApiConfigName ?? "default", @@ -2074,6 +2078,8 @@ export class ClineProvider language: stateValues.language ?? formatLanguage(vscode.env.language), mcpEnabled: stateValues.mcpEnabled ?? true, enableMcpServerCreation: stateValues.enableMcpServerCreation ?? true, + mcpMaxImagesPerResponse: stateValues.mcpMaxImagesPerResponse ?? 20, + mcpMaxImageSizeMB: stateValues.mcpMaxImageSizeMB ?? 10, alwaysApproveResubmit: stateValues.alwaysApproveResubmit ?? false, requestDelaySeconds: Math.max(5, stateValues.requestDelaySeconds ?? 10), currentApiConfigName: stateValues.currentApiConfigName ?? "default", diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 551810625c3..7f2cf9a04c8 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1181,6 +1181,14 @@ export const webviewMessageHandler = async ( await updateGlobalState("enableMcpServerCreation", message.bool ?? true) await provider.postStateToWebview() break + case "mcpMaxImagesPerResponse": + await updateGlobalState("mcpMaxImagesPerResponse", message.value ?? 20) + await provider.postStateToWebview() + break + case "mcpMaxImageSizeMB": + await updateGlobalState("mcpMaxImageSizeMB", message.value ?? 10) + await provider.postStateToWebview() + break case "remoteControlEnabled": try { await CloudService.instance.updateUserSettings({ extensionBridgeEnabled: message.bool ?? false }) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index aaddc520cb9..0a47a513671 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -309,6 +309,8 @@ export type ExtensionState = Pick< mcpEnabled: boolean enableMcpServerCreation: boolean + mcpMaxImagesPerResponse: number + mcpMaxImageSizeMB: number mode: Mode customModes: ModeConfig[] diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 93d0b9bc452..e2973aa8101 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -133,6 +133,8 @@ export interface WebviewMessage { | "terminalCompressProgressBar" | "mcpEnabled" | "enableMcpServerCreation" + | "mcpMaxImagesPerResponse" + | "mcpMaxImageSizeMB" | "remoteControlEnabled" | "taskSyncEnabled" | "searchCommits" diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index 21ad1c26525..e4042ad46ed 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -7,6 +7,7 @@ import { VSCodePanels, VSCodePanelTab, VSCodePanelView, + VSCodeTextField, } from "@vscode/webview-ui-toolkit/react" import { McpServer } from "@roo/mcp" @@ -45,6 +46,10 @@ const McpView = ({ onDone }: McpViewProps) => { mcpEnabled, enableMcpServerCreation, setEnableMcpServerCreation, + mcpMaxImagesPerResponse, + setMcpMaxImagesPerResponse, + mcpMaxImageSizeMB, + setMcpMaxImageSizeMB, } = useExtensionState() const { t } = useAppTranslation() @@ -107,6 +112,52 @@ const McpView = ({ onDone }: McpViewProps) => {
+
+
+ { + const value = parseInt(e.target.value) || 20 + setMcpMaxImagesPerResponse(value) + vscode.postMessage({ type: "mcpMaxImagesPerResponse", value }) + }} + style={{ width: "100px" }}> + Max Images Per Response + +
+ The maximum number of images that can be returned in a single MCP tool response. + Additional images will be ignored to prevent performance issues. +
+
+ +
+ { + const value = parseFloat(e.target.value) || 10 + setMcpMaxImageSizeMB(value) + vscode.postMessage({ type: "mcpMaxImageSizeMB", value }) + }} + style={{ width: "100px" }}> + Max Image Size (MB) + +
+ The maximum size (in MB) for a single base64-encoded image from an MCP tool + response. Images exceeding this size will be ignored. +
+
+
+ {/* Server List */} {servers.length > 0 && (
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 5534686db66..b096dc6af6f 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -99,6 +99,10 @@ export interface ExtensionStateContextType extends ExtensionState { setMcpEnabled: (value: boolean) => void enableMcpServerCreation: boolean setEnableMcpServerCreation: (value: boolean) => void + mcpMaxImagesPerResponse: number + setMcpMaxImagesPerResponse: (value: number) => void + mcpMaxImageSizeMB: number + setMcpMaxImageSizeMB: (value: number) => void remoteControlEnabled: boolean setRemoteControlEnabled: (value: boolean) => void taskSyncEnabled: boolean @@ -204,6 +208,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode terminalShellIntegrationTimeout: 4000, mcpEnabled: true, enableMcpServerCreation: false, + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, remoteControlEnabled: false, taskSyncEnabled: false, featureRoomoteControlEnabled: false, @@ -471,10 +477,13 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), setEnableMcpServerCreation: (value) => setState((prevState) => ({ ...prevState, enableMcpServerCreation: value })), + setMcpMaxImagesPerResponse: (value) => + setState((prevState) => ({ ...prevState, mcpMaxImagesPerResponse: value })), + setMcpMaxImageSizeMB: (value) => setState((prevState) => ({ ...prevState, mcpMaxImageSizeMB: value })), setRemoteControlEnabled: (value) => setState((prevState) => ({ ...prevState, remoteControlEnabled: value })), setTaskSyncEnabled: (value) => setState((prevState) => ({ ...prevState, taskSyncEnabled: value }) as any), setFeatureRoomoteControlEnabled: (value) => - setState((prevState) => ({ ...prevState, featureRoomoteControlEnabled: value })), + setState((prevState) => ({ ...prevState, featureRoomoteControlEnabled: value })), setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })), setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })), setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 33d7dc0ec7a..0992af179e1 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -185,6 +185,8 @@ describe("mergeExtensionState", () => { version: "", mcpEnabled: false, enableMcpServerCreation: false, + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, clineMessages: [], taskHistory: [], shouldShowAnnouncement: false, From dfa287b1e03af4ea96ace0d57503fb88f842151a Mon Sep 17 00:00:00 2001 From: Shivang Agrawal Date: Sun, 29 Jun 2025 20:20:54 +0530 Subject: [PATCH 04/10] refactor: centralize supported image types in MCP tool processing **Changes:** - Introduced a constant `SUPPORTED_IMAGE_TYPES` to define valid image MIME types, improving code readability and maintainability. - Updated `processToolContent` to utilize the new constant for image type validation. **Files Modified:** - `src/core/tools/useMcpToolTool.ts` --- src/core/tools/useMcpToolTool.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/useMcpToolTool.ts index c2f232bb68c..d7121297487 100644 --- a/src/core/tools/useMcpToolTool.ts +++ b/src/core/tools/useMcpToolTool.ts @@ -5,6 +5,8 @@ import { ClineAskUseMcpServer } from "../../shared/ExtensionMessage" import { McpExecutionStatus } from "@roo-code/types" import { t } from "../../i18n" +const SUPPORTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] + interface McpToolParams { server_name?: string tool_name?: string @@ -230,8 +232,7 @@ async function processToolContent(toolResult: any, cline: Task): Promise<{ text: } if (item.mimeType && item.data !== undefined && item.data !== null) { - const validImageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"] - if (validImageTypes.includes(item.mimeType)) { + if (SUPPORTED_IMAGE_TYPES.includes(item.mimeType)) { try { // Validate base64 data before constructing data URL if (typeof item.data !== "string" || item.data.trim() === "") { From 60e86b4ad71cd4280b450f94141d4091b0c3d806 Mon Sep 17 00:00:00 2001 From: Shivang Agrawal Date: Sun, 29 Jun 2025 20:21:17 +0530 Subject: [PATCH 05/10] test: add image validation tests for unsupported and malformed MIME types **Changes:** - Added tests to ensure that the MCP tool correctly ignores images with unsupported MIME types and malformed content. - Implemented console warnings for unsupported MIME types and missing properties in image content. **Files Modified:** - `src/core/tools/__tests__/useMcpToolTool.spec.ts` --- .../tools/__tests__/useMcpToolTool.spec.ts | 204 ++++++++++++++++++ .../webview/__tests__/ClineProvider.spec.ts | 2 + 2 files changed, 206 insertions(+) diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 513ab461424..b943ad78d3a 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -755,6 +755,210 @@ describe("useMcpToolTool", () => { consoleSpy.mockRestore() }) + it("should ignore images with unsupported MIME types", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Generated content with different image types:" }, + { + type: "image", + data: "PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCI+PGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iNDAiLz48L3N2Zz4=", + mimeType: "image/svg+xml", // Unsupported MIME type + }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", // Supported MIME type + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should only include the supported PNG image, not the SVG + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(1) + expect(sayCall[2][0]).toContain("data:image/png;base64,") + + // Should log warning about unsupported MIME type + expect(consoleSpy).toHaveBeenCalledWith("Unsupported image MIME type: image/svg+xml") + + expect(mockPushToolResult).toHaveBeenCalledWith( + "Tool result: Generated content with different image types: (with 1 images)", + ) + + consoleSpy.mockRestore() + }) + + it("should ignore malformed image content missing data property", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Generated content with malformed image:" }, + { + type: "image", + // Missing data property + mimeType: "image/png", + }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", // Valid image + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should only include the valid image, not the malformed one + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(1) + expect(sayCall[2][0]).toContain("data:image/png;base64,") + + // Should log warning about missing data property + expect(consoleSpy).toHaveBeenCalledWith("Invalid MCP ImageContent: missing data or mimeType") + + expect(mockPushToolResult).toHaveBeenCalledWith( + "Tool result: Generated content with malformed image: (with 1 images)", + ) + + consoleSpy.mockRestore() + }) + + it("should ignore malformed image content missing mimeType property", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Generated content with malformed image:" }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + // Missing mimeType property + }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", // Valid image + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should only include the valid image, not the malformed one + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(1) + expect(sayCall[2][0]).toContain("data:image/png;base64,") + + // Should log warning about missing mimeType property + expect(consoleSpy).toHaveBeenCalledWith("Invalid MCP ImageContent: missing data or mimeType") + + expect(mockPushToolResult).toHaveBeenCalledWith( + "Tool result: Generated content with malformed image: (with 1 images)", + ) + + consoleSpy.mockRestore() + }) + it("should handle user rejection", async () => { const block: ToolUse = { type: "tool_use", diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bd4608c6eb2..136b31b8585 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -554,6 +554,8 @@ describe("ClineProvider", () => { diagnosticsEnabled: true, openRouterImageApiKey: undefined, openRouterImageGenerationSelectedModel: undefined, + mcpMaxImagesPerResponse: 10, + mcpMaxImageSizeMB: 10, remoteControlEnabled: false, taskSyncEnabled: false, featureRoomoteControlEnabled: false, From 41471141184bfe30292d1cf6c86fbf40ad18b576 Mon Sep 17 00:00:00 2001 From: Shivang Agrawal Date: Wed, 9 Jul 2025 04:42:14 +0530 Subject: [PATCH 06/10] Add validation and i18n for MCP image settings - Add input validation for max images per response (1-100 range) - Add input validation for max image size in MB (1-100 range) - Display error messages for invalid inputs - Internationalize all image settings labels and descriptions - Update translations across all supported locales --- webview-ui/src/components/mcp/McpView.tsx | 54 +++++++++++++++++----- webview-ui/src/i18n/locales/ca/mcp.json | 7 +++ webview-ui/src/i18n/locales/de/mcp.json | 7 +++ webview-ui/src/i18n/locales/en/mcp.json | 7 +++ webview-ui/src/i18n/locales/es/mcp.json | 7 +++ webview-ui/src/i18n/locales/fr/mcp.json | 7 +++ webview-ui/src/i18n/locales/hi/mcp.json | 7 +++ webview-ui/src/i18n/locales/id/mcp.json | 7 +++ webview-ui/src/i18n/locales/it/mcp.json | 7 +++ webview-ui/src/i18n/locales/ja/mcp.json | 7 +++ webview-ui/src/i18n/locales/ko/mcp.json | 7 +++ webview-ui/src/i18n/locales/nl/mcp.json | 7 +++ webview-ui/src/i18n/locales/pl/mcp.json | 7 +++ webview-ui/src/i18n/locales/pt-BR/mcp.json | 7 +++ webview-ui/src/i18n/locales/ru/mcp.json | 7 +++ webview-ui/src/i18n/locales/tr/mcp.json | 7 +++ webview-ui/src/i18n/locales/vi/mcp.json | 7 +++ webview-ui/src/i18n/locales/zh-CN/mcp.json | 7 +++ webview-ui/src/i18n/locales/zh-TW/mcp.json | 7 +++ 19 files changed, 168 insertions(+), 12 deletions(-) diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index e4042ad46ed..b35b0585c57 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -53,6 +53,8 @@ const McpView = ({ onDone }: McpViewProps) => { } = useExtensionState() const { t } = useAppTranslation() + const [maxImagesError, setMaxImagesError] = useState("") + const [maxSizeError, setMaxSizeError] = useState("") return ( @@ -117,21 +119,35 @@ const McpView = ({ onDone }: McpViewProps) => { { - const value = parseInt(e.target.value) || 20 - setMcpMaxImagesPerResponse(value) - vscode.postMessage({ type: "mcpMaxImagesPerResponse", value }) + const value = e.target.value + if (value === "") { + setMaxImagesError("") + return + } + const numValue = parseInt(value, 10) + if (isNaN(numValue) || numValue < 1 || numValue > 100) { + setMaxImagesError(t("mcp:imageSettings.validationError")) + } else { + setMaxImagesError("") + setMcpMaxImagesPerResponse(numValue) + vscode.postMessage({ type: "mcpMaxImagesPerResponse", value: numValue }) + } }} style={{ width: "100px" }}> - Max Images Per Response + {t("mcp:imageSettings.maxImagesLabel")} + {maxImagesError && ( +
+ {maxImagesError} +
+ )}
- The maximum number of images that can be returned in a single MCP tool response. - Additional images will be ignored to prevent performance issues. + {t("mcp:imageSettings.maxImagesDescription")}
@@ -139,21 +155,35 @@ const McpView = ({ onDone }: McpViewProps) => { { - const value = parseFloat(e.target.value) || 10 - setMcpMaxImageSizeMB(value) - vscode.postMessage({ type: "mcpMaxImageSizeMB", value }) + const value = e.target.value + if (value === "") { + setMaxSizeError("") + return + } + const numValue = parseFloat(value) + if (isNaN(numValue) || numValue < 1 || numValue > 100) { + setMaxSizeError(t("mcp:imageSettings.validationError")) + } else { + setMaxSizeError("") + setMcpMaxImageSizeMB(numValue) + vscode.postMessage({ type: "mcpMaxImageSizeMB", value: numValue }) + } }} style={{ width: "100px" }}> - Max Image Size (MB) + {t("mcp:imageSettings.maxSizeLabel")} + {maxSizeError && ( +
+ {maxSizeError} +
+ )}
- The maximum size (in MB) for a single base64-encoded image from an MCP tool - response. Images exceeding this size will be ignored. + {t("mcp:imageSettings.maxSizeDescription")}
diff --git a/webview-ui/src/i18n/locales/ca/mcp.json b/webview-ui/src/i18n/locales/ca/mcp.json index f4f89440fd3..d89c69840c9 100644 --- a/webview-ui/src/i18n/locales/ca/mcp.json +++ b/webview-ui/src/i18n/locales/ca/mcp.json @@ -61,5 +61,12 @@ "running": "En execució", "completed": "Completat", "error": "Error" + }, + "imageSettings": { + "maxImagesLabel": "Màxim d'imatges per resposta", + "maxSizeLabel": "Mida màxima de la imatge (MB)", + "maxImagesDescription": "El nombre màxim d'imatges que es poden enviar en una sola resposta.", + "maxSizeDescription": "La mida màxima de cada imatge en megabytes.", + "validationError": "Si us plau, introduïu un número entre 1 i 100." } } diff --git a/webview-ui/src/i18n/locales/de/mcp.json b/webview-ui/src/i18n/locales/de/mcp.json index c44933dd77f..d423a330f6a 100644 --- a/webview-ui/src/i18n/locales/de/mcp.json +++ b/webview-ui/src/i18n/locales/de/mcp.json @@ -61,5 +61,12 @@ "running": "Wird ausgeführt", "completed": "Abgeschlossen", "error": "Fehler" + }, + "imageSettings": { + "maxImagesLabel": "Maximale Bilder pro Antwort", + "maxSizeLabel": "Maximale Bildgröße (MB)", + "maxImagesDescription": "Die maximale Anzahl von Bildern, die in einer einzigen Antwort gesendet werden können.", + "maxSizeDescription": "Die maximale Größe jedes Bildes in Megabyte.", + "validationError": "Bitte geben Sie eine Zahl zwischen 1 und 100 ein." } } diff --git a/webview-ui/src/i18n/locales/en/mcp.json b/webview-ui/src/i18n/locales/en/mcp.json index 5bc64a70dca..139458a6e11 100644 --- a/webview-ui/src/i18n/locales/en/mcp.json +++ b/webview-ui/src/i18n/locales/en/mcp.json @@ -61,5 +61,12 @@ "running": "Running", "completed": "Completed", "error": "Error" + }, + "imageSettings": { + "maxImagesLabel": "Max Images", + "maxImagesDescription": "The maximum number of images that can be included in a single request.", + "maxSizeLabel": "Max Size (MB)", + "maxSizeDescription": "The maximum file size for each image, in megabytes.", + "validationError": "Please enter a valid number between 1 and 100." } } diff --git a/webview-ui/src/i18n/locales/es/mcp.json b/webview-ui/src/i18n/locales/es/mcp.json index 5f77b5547e2..ccac9d240bf 100644 --- a/webview-ui/src/i18n/locales/es/mcp.json +++ b/webview-ui/src/i18n/locales/es/mcp.json @@ -61,5 +61,12 @@ "running": "Ejecutando", "completed": "Completado", "error": "Error" + }, + "imageSettings": { + "maxImagesLabel": "Máximo de imágenes por respuesta", + "maxSizeLabel": "Tamaño máximo de imagen (MB)", + "maxImagesDescription": "El número máximo de imágenes que se pueden enviar en una sola respuesta.", + "maxSizeDescription": "El tamaño máximo de cada imagen en megabytes.", + "validationError": "Por favor, introduce un número entre 1 y 100." } } diff --git a/webview-ui/src/i18n/locales/fr/mcp.json b/webview-ui/src/i18n/locales/fr/mcp.json index 7f88c0094ee..ada5a343875 100644 --- a/webview-ui/src/i18n/locales/fr/mcp.json +++ b/webview-ui/src/i18n/locales/fr/mcp.json @@ -61,5 +61,12 @@ "running": "En cours", "completed": "Terminé", "error": "Erreur" + }, + "imageSettings": { + "maxImagesLabel": "Nombre maximum d'images par réponse", + "maxSizeLabel": "Taille maximale de l'image (Mo)", + "maxImagesDescription": "Le nombre maximum d'images pouvant être envoyées en une seule réponse.", + "maxSizeDescription": "La taille maximale de chaque image en mégaoctets.", + "validationError": "Veuillez saisir un nombre entre 1 et 100." } } diff --git a/webview-ui/src/i18n/locales/hi/mcp.json b/webview-ui/src/i18n/locales/hi/mcp.json index 6160c07169f..9dda4474618 100644 --- a/webview-ui/src/i18n/locales/hi/mcp.json +++ b/webview-ui/src/i18n/locales/hi/mcp.json @@ -61,5 +61,12 @@ "running": "चल रहा है", "completed": "पूरा हुआ", "error": "त्रुटि" + }, + "imageSettings": { + "maxImagesLabel": "अधिकतम छवियाँ", + "maxSizeLabel": "अधिकतम छवि आकार (एमबी)", + "maxImagesDescription": "एक ही प्रतिक्रिया में भेजी जा सकने वाली छवियों की अधिकतम संख्या।", + "maxSizeDescription": "प्रत्येक छवि का अधिकतम आकार मेगाबाइट में।", + "validationError": "कृपया 1 और 100 के बीच एक संख्या दर्ज करें।" } } diff --git a/webview-ui/src/i18n/locales/id/mcp.json b/webview-ui/src/i18n/locales/id/mcp.json index 0786f3168f9..3d6a7321679 100644 --- a/webview-ui/src/i18n/locales/id/mcp.json +++ b/webview-ui/src/i18n/locales/id/mcp.json @@ -61,5 +61,12 @@ "running": "Berjalan", "completed": "Selesai", "error": "Error" + }, + "imageSettings": { + "maxImagesLabel": "Gambar Maks per Respons", + "maxSizeLabel": "Ukuran Gambar Maks (MB)", + "maxImagesDescription": "Jumlah maksimum gambar yang dapat dikirim dalam satu respons.", + "maxSizeDescription": "Ukuran maksimum setiap gambar dalam megabita.", + "validationError": "Harap masukkan angka antara 1 dan 100." } } diff --git a/webview-ui/src/i18n/locales/it/mcp.json b/webview-ui/src/i18n/locales/it/mcp.json index c35c248d943..970f43e887f 100644 --- a/webview-ui/src/i18n/locales/it/mcp.json +++ b/webview-ui/src/i18n/locales/it/mcp.json @@ -61,5 +61,12 @@ "running": "In esecuzione", "completed": "Completato", "error": "Errore" + }, + "imageSettings": { + "maxImagesLabel": "Numero massimo di immagini per risposta", + "maxSizeLabel": "Dimensione massima dell'immagine (MB)", + "maxImagesDescription": "Il numero massimo di immagini che possono essere inviate in una singola risposta.", + "maxSizeDescription": "La dimensione massima di ogni immagine in megabyte.", + "validationError": "Inserisci un numero compreso tra 1 e 100." } } diff --git a/webview-ui/src/i18n/locales/ja/mcp.json b/webview-ui/src/i18n/locales/ja/mcp.json index 7c84a184814..d17e7ff67ae 100644 --- a/webview-ui/src/i18n/locales/ja/mcp.json +++ b/webview-ui/src/i18n/locales/ja/mcp.json @@ -61,5 +61,12 @@ "running": "実行中", "completed": "完了", "error": "エラー" + }, + "imageSettings": { + "maxImagesLabel": "応答あたりの最大画像数", + "maxSizeLabel": "最大画像サイズ (MB)", + "maxImagesDescription": "1回の応答で送信できる画像の最大数。", + "maxSizeDescription": "各画像の最大サイズ(メガバイト)。", + "validationError": "1から100までの数値を入力してください。" } } diff --git a/webview-ui/src/i18n/locales/ko/mcp.json b/webview-ui/src/i18n/locales/ko/mcp.json index d3f08e795ea..e392e82b38d 100644 --- a/webview-ui/src/i18n/locales/ko/mcp.json +++ b/webview-ui/src/i18n/locales/ko/mcp.json @@ -61,5 +61,12 @@ "running": "실행 중", "completed": "완료됨", "error": "오류" + }, + "imageSettings": { + "maxImagesLabel": "응답당 최대 이미지 수", + "maxSizeLabel": "최대 이미지 크기 (MB)", + "maxImagesDescription": "단일 응답으로 보낼 수 있는 최대 이미지 수입니다.", + "maxSizeDescription": "각 이미지의 최대 크기(메가바이트)입니다.", + "validationError": "1에서 100 사이의 숫자를 입력하세요." } } diff --git a/webview-ui/src/i18n/locales/nl/mcp.json b/webview-ui/src/i18n/locales/nl/mcp.json index 3222b87498d..c49416d7bcf 100644 --- a/webview-ui/src/i18n/locales/nl/mcp.json +++ b/webview-ui/src/i18n/locales/nl/mcp.json @@ -62,5 +62,12 @@ "running": "Wordt uitgevoerd", "completed": "Voltooid", "error": "Fout" + }, + "imageSettings": { + "maxImagesLabel": "Max afbeeldingen per antwoord", + "maxSizeLabel": "Max afbeeldingsgrootte (MB)", + "maxImagesDescription": "Het maximale aantal afbeeldingen dat in één antwoord kan worden verzonden.", + "maxSizeDescription": "De maximale grootte van elke afbeelding in megabytes.", + "validationError": "Voer een getal in tussen 1 en 100." } } diff --git a/webview-ui/src/i18n/locales/pl/mcp.json b/webview-ui/src/i18n/locales/pl/mcp.json index 02eccfe69f3..ddc1d6d4016 100644 --- a/webview-ui/src/i18n/locales/pl/mcp.json +++ b/webview-ui/src/i18n/locales/pl/mcp.json @@ -61,5 +61,12 @@ "running": "Uruchomione", "completed": "Zakończone", "error": "Błąd" + }, + "imageSettings": { + "maxImagesLabel": "Maksymalna liczba obrazów na odpowiedź", + "maxSizeLabel": "Maksymalny rozmiar obrazu (MB)", + "maxImagesDescription": "Maksymalna liczba obrazów, które można wysłać w jednej odpowiedzi.", + "maxSizeDescription": "Maksymalny rozmiar każdego obrazu w megabajtach.", + "validationError": "Wprowadź liczbę od 1 do 100." } } diff --git a/webview-ui/src/i18n/locales/pt-BR/mcp.json b/webview-ui/src/i18n/locales/pt-BR/mcp.json index e5018762a72..771cd7a37bd 100644 --- a/webview-ui/src/i18n/locales/pt-BR/mcp.json +++ b/webview-ui/src/i18n/locales/pt-BR/mcp.json @@ -61,5 +61,12 @@ "running": "Em execução", "completed": "Concluído", "error": "Erro" + }, + "imageSettings": { + "maxImagesLabel": "Máximo de imagens por resposta", + "maxSizeLabel": "Tamanho máximo da imagem (MB)", + "maxImagesDescription": "O número máximo de imagens que podem ser enviadas em uma única resposta.", + "maxSizeDescription": "O tamanho máximo de cada imagem em megabytes.", + "validationError": "Por favor, insira um número entre 1 e 100." } } diff --git a/webview-ui/src/i18n/locales/ru/mcp.json b/webview-ui/src/i18n/locales/ru/mcp.json index 3e7ef5f3ae8..227da0b2d8a 100644 --- a/webview-ui/src/i18n/locales/ru/mcp.json +++ b/webview-ui/src/i18n/locales/ru/mcp.json @@ -61,5 +61,12 @@ "running": "Выполняется", "completed": "Завершено", "error": "Ошибка" + }, + "imageSettings": { + "maxImagesLabel": "Максимум изображений в ответе", + "maxSizeLabel": "Максимальный размер изображения (МБ)", + "maxImagesDescription": "Максимальное количество изображений, которое можно отправить в одном ответе.", + "maxSizeDescription": "Максимальный размер каждого изображения в мегабайтах.", + "validationError": "Пожалуйста, введите число от 1 до 100." } } diff --git a/webview-ui/src/i18n/locales/tr/mcp.json b/webview-ui/src/i18n/locales/tr/mcp.json index fa471722440..4a27bfb9d43 100644 --- a/webview-ui/src/i18n/locales/tr/mcp.json +++ b/webview-ui/src/i18n/locales/tr/mcp.json @@ -61,5 +61,12 @@ "running": "Çalışıyor", "completed": "Tamamlandı", "error": "Hata" + }, + "imageSettings": { + "maxImagesLabel": "Yanıt Başına Maksimum Görüntü", + "maxSizeLabel": "Maksimum Görüntü Boyutu (MB)", + "maxImagesDescription": "Tek bir yanıtta gönderilebilecek maksimum görüntü sayısı.", + "maxSizeDescription": "Her görüntünün megabayt cinsinden maksimum boyutu.", + "validationError": "Lütfen 1 ile 100 arasında bir sayı girin." } } diff --git a/webview-ui/src/i18n/locales/vi/mcp.json b/webview-ui/src/i18n/locales/vi/mcp.json index 05009902d5c..9b504537a77 100644 --- a/webview-ui/src/i18n/locales/vi/mcp.json +++ b/webview-ui/src/i18n/locales/vi/mcp.json @@ -61,5 +61,12 @@ "running": "Đang chạy", "completed": "Hoàn thành", "error": "Lỗi" + }, + "imageSettings": { + "maxImagesLabel": "Số lượng hình ảnh tối đa mỗi phản hồi", + "maxSizeLabel": "Kích thước hình ảnh tối đa (MB)", + "maxImagesDescription": "Số lượng hình ảnh tối đa có thể được gửi trong một phản hồi duy nhất.", + "maxSizeDescription": "Kích thước tối đa của mỗi hình ảnh tính bằng megabyte.", + "validationError": "Vui lòng nhập một số từ 1 đến 100." } } diff --git a/webview-ui/src/i18n/locales/zh-CN/mcp.json b/webview-ui/src/i18n/locales/zh-CN/mcp.json index 775250b19f0..df56936e92c 100644 --- a/webview-ui/src/i18n/locales/zh-CN/mcp.json +++ b/webview-ui/src/i18n/locales/zh-CN/mcp.json @@ -61,5 +61,12 @@ "running": "运行中", "completed": "已完成", "error": "错误" + }, + "imageSettings": { + "maxImagesLabel": "每个响应的最大图像数", + "maxSizeLabel": "最大图像大小 (MB)", + "maxImagesDescription": "单个响应中可以发送的最大图像数。", + "maxSizeDescription": "每个图像的最大大小(以兆字节为单位)。", + "validationError": "请输入一个 1 到 100 之间的数字。" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/mcp.json b/webview-ui/src/i18n/locales/zh-TW/mcp.json index e5fc6f66e5a..3d9661c65cc 100644 --- a/webview-ui/src/i18n/locales/zh-TW/mcp.json +++ b/webview-ui/src/i18n/locales/zh-TW/mcp.json @@ -61,5 +61,12 @@ "running": "執行中", "completed": "已完成", "error": "錯誤" + }, + "imageSettings": { + "maxImagesLabel": "每個回應的最大圖片數", + "maxSizeLabel": "最大圖片大小 (MB)", + "maxImagesDescription": "單一回應中可傳送的圖片數量上限。", + "maxSizeDescription": "每張圖片的大小上限(以 MB 為單位)。", + "validationError": "請輸入一個 1 到 100 之間的數字。" } } From 4cc60df9aeffaf304ad4d4fffe80842b900d08e8 Mon Sep 17 00:00:00 2001 From: Shivang Agrawal Date: Tue, 29 Jul 2025 03:11:44 +0530 Subject: [PATCH 07/10] Improve MCP image handling with size validation and UI enhancements - Add early size check to prevent memory spikes from oversized images - Implement bounds checking for MCP settings (images per response, max size) - Add image count tooltips and improved accessibility in UI - Update translations for better user experience across EN/ES/FR - Refactor base64 validation with reusable regex constant --- src/core/tools/__tests__/useMcpToolTool.spec.ts | 4 ++-- src/core/tools/useMcpToolTool.ts | 17 +++++++++++++---- webview-ui/src/components/chat/McpExecution.tsx | 4 +++- webview-ui/src/components/common/Thumbnails.tsx | 6 ++++-- webview-ui/src/i18n/locales/en/common.json | 4 ++++ webview-ui/src/i18n/locales/en/mcp.json | 3 ++- webview-ui/src/i18n/locales/es/common.json | 4 ++++ webview-ui/src/i18n/locales/es/mcp.json | 3 ++- webview-ui/src/i18n/locales/fr/common.json | 4 ++++ webview-ui/src/i18n/locales/fr/mcp.json | 3 ++- 10 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index b943ad78d3a..725030f304a 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -743,9 +743,9 @@ describe("useMcpToolTool", () => { expect(sayCall[2]).toHaveLength(1) expect(sayCall[2][0]).toContain(smallBase64) - // Should log warning about size exceeding limit + // Should log warning about size exceeding limit (either early check or full validation) expect(consoleSpy).toHaveBeenCalledWith( - expect.stringMatching(/MCP image exceeds size limit: .* > 1MB\. Image will be ignored\./), + expect.stringMatching(/MCP image (likely exceeds size limit|exceeds size limit)/), ) expect(mockPushToolResult).toHaveBeenCalledWith( diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/useMcpToolTool.ts index d7121297487..25ccec4190e 100644 --- a/src/core/tools/useMcpToolTool.ts +++ b/src/core/tools/useMcpToolTool.ts @@ -6,6 +6,7 @@ import { McpExecutionStatus } from "@roo-code/types" import { t } from "../../i18n" const SUPPORTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +const BASE64_REGEX = /^[A-Za-z0-9+/]*={0,2}$/ interface McpToolParams { server_name?: string @@ -216,8 +217,8 @@ async function processToolContent(toolResult: any, cline: Task): Promise<{ text: // Get MCP settings from the extension's global state const state = await cline.providerRef.deref()?.getState() - const maxImagesPerResponse = state?.mcpMaxImagesPerResponse ?? 20 - const maxImageSizeMB = state?.mcpMaxImageSizeMB ?? 10 + const maxImagesPerResponse = Math.max(1, Math.min(100, state?.mcpMaxImagesPerResponse ?? 20)) + const maxImageSizeMB = Math.max(0.1, Math.min(50, state?.mcpMaxImageSizeMB ?? 10)) toolResult.content.forEach((item: any) => { if (item.type === "text") { @@ -240,9 +241,17 @@ async function processToolContent(toolResult: any, cline: Task): Promise<{ text: return } + // Quick size check before full validation to prevent memory spikes + const approximateSizeMB = (item.data.length * 0.75) / (1024 * 1024) + if (approximateSizeMB > maxImageSizeMB * 1.5) { + console.warn( + `MCP image likely exceeds size limit based on string length: ~${approximateSizeMB.toFixed(2)}MB`, + ) + return + } + // Basic validation for base64 format - const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/ - if (!base64Regex.test(item.data.replace(/\s/g, ""))) { + if (!BASE64_REGEX.test(item.data.replace(/\s/g, ""))) { console.warn("Invalid MCP ImageContent: base64 data contains invalid characters") return } diff --git a/webview-ui/src/components/chat/McpExecution.tsx b/webview-ui/src/components/chat/McpExecution.tsx index 5ffdbe03f0e..71eb10dc042 100644 --- a/webview-ui/src/components/chat/McpExecution.tsx +++ b/webview-ui/src/components/chat/McpExecution.tsx @@ -218,7 +218,9 @@ export const McpExecution = ({ {(responseText && responseText.length > 0) || (images && images.length > 0) ? (
{images && images.length > 0 && ( -
+
{images.length}
diff --git a/webview-ui/src/components/common/Thumbnails.tsx b/webview-ui/src/components/common/Thumbnails.tsx index 0b8d88bce10..24ace0c599f 100644 --- a/webview-ui/src/components/common/Thumbnails.tsx +++ b/webview-ui/src/components/common/Thumbnails.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useLayoutEffect, memo } from "react" import { useWindowSize } from "react-use" +import { useTranslation } from "react-i18next" import { vscode } from "@src/utils/vscode" interface ThumbnailsProps { @@ -10,6 +11,7 @@ interface ThumbnailsProps { } const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProps) => { + const { t } = useTranslation("common") const [hoveredIndex, setHoveredIndex] = useState(null) const [failedImages, setFailedImages] = useState>(new Set()) const containerRef = useRef(null) @@ -75,7 +77,7 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp cursor: "pointer", }} onClick={() => handleImageClick(image)} - title="Failed to load image"> + title={t("thumbnails.failedToLoad")}> Date: Tue, 9 Sep 2025 04:02:04 +0530 Subject: [PATCH 08/10] perf: optimize MCP image processing with parallel validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract image validation into separate validateAndProcessImage function - Replace sequential forEach with parallel Promise.all processing - Pre-filter and limit images before validation for better efficiency - Improve performance for MCP tools returning multiple images 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/core/tools/useMcpToolTool.ts | 120 +++++++++++++++++-------------- 1 file changed, 65 insertions(+), 55 deletions(-) diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/useMcpToolTool.ts index 25ccec4190e..403372cc647 100644 --- a/src/core/tools/useMcpToolTool.ts +++ b/src/core/tools/useMcpToolTool.ts @@ -207,82 +207,92 @@ function calculateImageSizeMB(base64Data: string): number { return sizeInBytes / (1024 * 1024) // Convert to MB } +async function validateAndProcessImage(item: any, maxImageSizeMB: number): Promise { + if (!item.mimeType || item.data === undefined || item.data === null) { + console.warn("Invalid MCP ImageContent: missing data or mimeType") + return null + } + + if (!SUPPORTED_IMAGE_TYPES.includes(item.mimeType)) { + console.warn(`Unsupported image MIME type: ${item.mimeType}`) + return null + } + + try { + // Validate base64 data before constructing data URL + if (typeof item.data !== "string" || item.data.trim() === "") { + console.warn("Invalid MCP ImageContent: base64 data is not a valid string") + return null + } + + // Quick size check before full validation to prevent memory spikes + const approximateSizeMB = (item.data.length * 0.75) / (1024 * 1024) + if (approximateSizeMB > maxImageSizeMB * 1.5) { + console.warn( + `MCP image likely exceeds size limit based on string length: ~${approximateSizeMB.toFixed(2)}MB`, + ) + return null + } + + // Basic validation for base64 format + if (!BASE64_REGEX.test(item.data.replace(/\s/g, ""))) { + console.warn("Invalid MCP ImageContent: base64 data contains invalid characters") + return null + } + + // Check image size + const imageSizeMB = calculateImageSizeMB(item.data) + if (imageSizeMB > maxImageSizeMB) { + console.warn( + `MCP image exceeds size limit: ${imageSizeMB.toFixed(2)}MB > ${maxImageSizeMB}MB. Image will be ignored.`, + ) + return null + } + + return `data:${item.mimeType};base64,${item.data}` + } catch (error) { + console.warn("Failed to process MCP image content:", error) + return null + } +} + async function processToolContent(toolResult: any, cline: Task): Promise<{ text: string; images: string[] }> { if (!toolResult?.content || toolResult.content.length === 0) { return { text: "", images: [] } } const textParts: string[] = [] - const images: string[] = [] // Get MCP settings from the extension's global state const state = await cline.providerRef.deref()?.getState() const maxImagesPerResponse = Math.max(1, Math.min(100, state?.mcpMaxImagesPerResponse ?? 20)) const maxImageSizeMB = Math.max(0.1, Math.min(50, state?.mcpMaxImageSizeMB ?? 10)) + // Separate content by type for efficient processing + const imageItems = toolResult.content.filter((item: any) => item.type === "image").slice(0, maxImagesPerResponse) // Limit images before processing + + // Process images in parallel + const validatedImages = await Promise.all( + imageItems.map((item: any) => validateAndProcessImage(item, maxImageSizeMB)), + ) + const images = validatedImages.filter(Boolean) as string[] + + // Process other content types toolResult.content.forEach((item: any) => { if (item.type === "text") { textParts.push(item.text) - } else if (item.type === "image") { - // Check if we've exceeded the maximum number of images - if (images.length >= maxImagesPerResponse) { - console.warn( - `MCP response contains more than ${maxImagesPerResponse} images. Additional images will be ignored to prevent performance issues.`, - ) - return // Skip processing additional images - } - - if (item.mimeType && item.data !== undefined && item.data !== null) { - if (SUPPORTED_IMAGE_TYPES.includes(item.mimeType)) { - try { - // Validate base64 data before constructing data URL - if (typeof item.data !== "string" || item.data.trim() === "") { - console.warn("Invalid MCP ImageContent: base64 data is not a valid string") - return - } - - // Quick size check before full validation to prevent memory spikes - const approximateSizeMB = (item.data.length * 0.75) / (1024 * 1024) - if (approximateSizeMB > maxImageSizeMB * 1.5) { - console.warn( - `MCP image likely exceeds size limit based on string length: ~${approximateSizeMB.toFixed(2)}MB`, - ) - return - } - - // Basic validation for base64 format - if (!BASE64_REGEX.test(item.data.replace(/\s/g, ""))) { - console.warn("Invalid MCP ImageContent: base64 data contains invalid characters") - return - } - - // Check image size - const imageSizeMB = calculateImageSizeMB(item.data) - if (imageSizeMB > maxImageSizeMB) { - console.warn( - `MCP image exceeds size limit: ${imageSizeMB.toFixed(2)}MB > ${maxImageSizeMB}MB. Image will be ignored.`, - ) - return - } - - const dataUrl = `data:${item.mimeType};base64,${item.data}` - images.push(dataUrl) - } catch (error) { - console.warn("Failed to process MCP image content:", error) - // Continue processing other content instead of failing entirely - } - } else { - console.warn(`Unsupported image MIME type: ${item.mimeType}`) - } - } else { - console.warn("Invalid MCP ImageContent: missing data or mimeType") - } } else if (item.type === "resource") { const { blob: _, ...rest } = item.resource textParts.push(JSON.stringify(rest, null, 2)) } }) + if (imageItems.length > maxImagesPerResponse) { + console.warn( + `MCP response contains more than ${maxImagesPerResponse} images. Additional images will be ignored to prevent performance issues.`, + ) + } + return { text: textParts.filter(Boolean).join("\n\n"), images, From e936fdd364d612b86af6211ded431f870b84d7a6 Mon Sep 17 00:00:00 2001 From: Shivang Agrawal Date: Tue, 9 Sep 2025 04:18:18 +0530 Subject: [PATCH 09/10] fix: resolve failing MCP tool tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix image limiting warning logic by checking original array length before slicing - Add missing getState mock in test to support MCP settings access - Update test assertion to match actual function signature with images parameter 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/core/tools/__tests__/useMcpToolTool.spec.ts | 6 +++++- src/core/tools/useMcpToolTool.ts | 16 +++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 725030f304a..a3a7483405c 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -1147,6 +1147,10 @@ describe("useMcpToolTool", () => { callTool: vi.fn().mockResolvedValue(mockToolResult), }), postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), }) const block: ToolUse = { @@ -1174,7 +1178,7 @@ describe("useMcpToolTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockTask.recordToolError).not.toHaveBeenCalled() expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") - expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully", []) }) it("should reject unknown server names with available servers listed", async () => { diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/useMcpToolTool.ts index 403372cc647..550145ecf0a 100644 --- a/src/core/tools/useMcpToolTool.ts +++ b/src/core/tools/useMcpToolTool.ts @@ -269,7 +269,15 @@ async function processToolContent(toolResult: any, cline: Task): Promise<{ text: const maxImageSizeMB = Math.max(0.1, Math.min(50, state?.mcpMaxImageSizeMB ?? 10)) // Separate content by type for efficient processing - const imageItems = toolResult.content.filter((item: any) => item.type === "image").slice(0, maxImagesPerResponse) // Limit images before processing + const allImageItems = toolResult.content.filter((item: any) => item.type === "image") + const imageItems = allImageItems.slice(0, maxImagesPerResponse) // Limit images before processing + + // Check if we need to warn about exceeding the limit + if (allImageItems.length > maxImagesPerResponse) { + console.warn( + `MCP response contains more than ${maxImagesPerResponse} images. Additional images will be ignored to prevent performance issues.`, + ) + } // Process images in parallel const validatedImages = await Promise.all( @@ -287,12 +295,6 @@ async function processToolContent(toolResult: any, cline: Task): Promise<{ text: } }) - if (imageItems.length > maxImagesPerResponse) { - console.warn( - `MCP response contains more than ${maxImagesPerResponse} images. Additional images will be ignored to prevent performance issues.`, - ) - } - return { text: textParts.filter(Boolean).join("\n\n"), images, From e1e8e5f8a8256a0c4add4c89623bdde6192dbe5e Mon Sep 17 00:00:00 2001 From: Shivang Agrawal Date: Tue, 9 Sep 2025 04:26:38 +0530 Subject: [PATCH 10/10] fix: add missing translations for image handling in MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add missing thumbnail and image count translations for 15 languages: - thumbnails.failedToLoad and thumbnails.altText in common.json - execution.imageCountTooltip in mcp.json This fixes the check-translations CI failure by providing proper localized strings for the new image handling features. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- webview-ui/src/i18n/locales/ca/common.json | 4 ++++ webview-ui/src/i18n/locales/ca/mcp.json | 3 ++- webview-ui/src/i18n/locales/de/common.json | 4 ++++ webview-ui/src/i18n/locales/de/mcp.json | 3 ++- webview-ui/src/i18n/locales/hi/common.json | 4 ++++ webview-ui/src/i18n/locales/hi/mcp.json | 3 ++- webview-ui/src/i18n/locales/id/common.json | 4 ++++ webview-ui/src/i18n/locales/id/mcp.json | 3 ++- webview-ui/src/i18n/locales/it/common.json | 4 ++++ webview-ui/src/i18n/locales/it/mcp.json | 3 ++- webview-ui/src/i18n/locales/ja/common.json | 4 ++++ webview-ui/src/i18n/locales/ja/mcp.json | 3 ++- webview-ui/src/i18n/locales/ko/common.json | 4 ++++ webview-ui/src/i18n/locales/ko/mcp.json | 3 ++- webview-ui/src/i18n/locales/nl/common.json | 4 ++++ webview-ui/src/i18n/locales/nl/mcp.json | 3 ++- webview-ui/src/i18n/locales/pl/common.json | 4 ++++ webview-ui/src/i18n/locales/pl/mcp.json | 3 ++- webview-ui/src/i18n/locales/pt-BR/common.json | 4 ++++ webview-ui/src/i18n/locales/pt-BR/mcp.json | 3 ++- webview-ui/src/i18n/locales/ru/common.json | 4 ++++ webview-ui/src/i18n/locales/ru/mcp.json | 3 ++- webview-ui/src/i18n/locales/tr/common.json | 4 ++++ webview-ui/src/i18n/locales/tr/mcp.json | 3 ++- webview-ui/src/i18n/locales/vi/common.json | 4 ++++ webview-ui/src/i18n/locales/vi/mcp.json | 3 ++- webview-ui/src/i18n/locales/zh-CN/common.json | 4 ++++ webview-ui/src/i18n/locales/zh-CN/mcp.json | 3 ++- webview-ui/src/i18n/locales/zh-TW/common.json | 4 ++++ webview-ui/src/i18n/locales/zh-TW/mcp.json | 3 ++- 30 files changed, 90 insertions(+), 15 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/common.json b/webview-ui/src/i18n/locales/ca/common.json index 69f18d94115..6422f098734 100644 --- a/webview-ui/src/i18n/locales/ca/common.json +++ b/webview-ui/src/i18n/locales/ca/common.json @@ -95,5 +95,9 @@ "months_ago": "fa {{count}} mesos", "year_ago": "fa un any", "years_ago": "fa {{count}} anys" + }, + "thumbnails": { + "failedToLoad": "No s'ha pogut carregar la imatge", + "altText": "Miniatura {{index}}" } } diff --git a/webview-ui/src/i18n/locales/ca/mcp.json b/webview-ui/src/i18n/locales/ca/mcp.json index d89c69840c9..27f1e8f3a1d 100644 --- a/webview-ui/src/i18n/locales/ca/mcp.json +++ b/webview-ui/src/i18n/locales/ca/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "En execució", "completed": "Completat", - "error": "Error" + "error": "Error", + "imageCountTooltip": "{{count}} imatge(s) en la resposta" }, "imageSettings": { "maxImagesLabel": "Màxim d'imatges per resposta", diff --git a/webview-ui/src/i18n/locales/de/common.json b/webview-ui/src/i18n/locales/de/common.json index b21dba3b347..48180bdb8ae 100644 --- a/webview-ui/src/i18n/locales/de/common.json +++ b/webview-ui/src/i18n/locales/de/common.json @@ -95,5 +95,9 @@ "months_ago": "vor {{count}} Monaten", "year_ago": "vor einem Jahr", "years_ago": "vor {{count}} Jahren" + }, + "thumbnails": { + "failedToLoad": "Bild konnte nicht geladen werden", + "altText": "Vorschaubild {{index}}" } } diff --git a/webview-ui/src/i18n/locales/de/mcp.json b/webview-ui/src/i18n/locales/de/mcp.json index d423a330f6a..507cff5f56f 100644 --- a/webview-ui/src/i18n/locales/de/mcp.json +++ b/webview-ui/src/i18n/locales/de/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "Wird ausgeführt", "completed": "Abgeschlossen", - "error": "Fehler" + "error": "Fehler", + "imageCountTooltip": "{{count}} Bild(er) in der Antwort" }, "imageSettings": { "maxImagesLabel": "Maximale Bilder pro Antwort", diff --git a/webview-ui/src/i18n/locales/hi/common.json b/webview-ui/src/i18n/locales/hi/common.json index 00b46dbb099..e78ab9afe04 100644 --- a/webview-ui/src/i18n/locales/hi/common.json +++ b/webview-ui/src/i18n/locales/hi/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} महीने पहले", "year_ago": "एक साल पहले", "years_ago": "{{count}} साल पहले" + }, + "thumbnails": { + "failedToLoad": "चित्र लोड नहीं हो सका", + "altText": "थंबनेल {{index}}" } } diff --git a/webview-ui/src/i18n/locales/hi/mcp.json b/webview-ui/src/i18n/locales/hi/mcp.json index 9dda4474618..8b2ee33e5e3 100644 --- a/webview-ui/src/i18n/locales/hi/mcp.json +++ b/webview-ui/src/i18n/locales/hi/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "चल रहा है", "completed": "पूरा हुआ", - "error": "त्रुटि" + "error": "त्रुटि", + "imageCountTooltip": "प्रतिक्रिया में {{count}} चित्र" }, "imageSettings": { "maxImagesLabel": "अधिकतम छवियाँ", diff --git a/webview-ui/src/i18n/locales/id/common.json b/webview-ui/src/i18n/locales/id/common.json index 697765e1c3a..e399bfce6ea 100644 --- a/webview-ui/src/i18n/locales/id/common.json +++ b/webview-ui/src/i18n/locales/id/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} bulan yang lalu", "year_ago": "satu tahun yang lalu", "years_ago": "{{count}} tahun yang lalu" + }, + "thumbnails": { + "failedToLoad": "Gagal memuat gambar", + "altText": "Thumbnail {{index}}" } } diff --git a/webview-ui/src/i18n/locales/id/mcp.json b/webview-ui/src/i18n/locales/id/mcp.json index 3d6a7321679..f0c850b8b7c 100644 --- a/webview-ui/src/i18n/locales/id/mcp.json +++ b/webview-ui/src/i18n/locales/id/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "Berjalan", "completed": "Selesai", - "error": "Error" + "error": "Error", + "imageCountTooltip": "{{count}} gambar dalam respons" }, "imageSettings": { "maxImagesLabel": "Gambar Maks per Respons", diff --git a/webview-ui/src/i18n/locales/it/common.json b/webview-ui/src/i18n/locales/it/common.json index e7fbed4d85c..5652f388544 100644 --- a/webview-ui/src/i18n/locales/it/common.json +++ b/webview-ui/src/i18n/locales/it/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} mesi fa", "year_ago": "un anno fa", "years_ago": "{{count}} anni fa" + }, + "thumbnails": { + "failedToLoad": "Impossibile caricare l'immagine", + "altText": "Anteprima {{index}}" } } diff --git a/webview-ui/src/i18n/locales/it/mcp.json b/webview-ui/src/i18n/locales/it/mcp.json index 970f43e887f..a669d3dad6b 100644 --- a/webview-ui/src/i18n/locales/it/mcp.json +++ b/webview-ui/src/i18n/locales/it/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "In esecuzione", "completed": "Completato", - "error": "Errore" + "error": "Errore", + "imageCountTooltip": "{{count}} immagine/i nella risposta" }, "imageSettings": { "maxImagesLabel": "Numero massimo di immagini per risposta", diff --git a/webview-ui/src/i18n/locales/ja/common.json b/webview-ui/src/i18n/locales/ja/common.json index 815da42952b..6558a48f24e 100644 --- a/webview-ui/src/i18n/locales/ja/common.json +++ b/webview-ui/src/i18n/locales/ja/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}}ヶ月前", "year_ago": "1年前", "years_ago": "{{count}}年前" + }, + "thumbnails": { + "failedToLoad": "画像の読み込みに失敗しました", + "altText": "サムネイル {{index}}" } } diff --git a/webview-ui/src/i18n/locales/ja/mcp.json b/webview-ui/src/i18n/locales/ja/mcp.json index d17e7ff67ae..dea55c5d1bd 100644 --- a/webview-ui/src/i18n/locales/ja/mcp.json +++ b/webview-ui/src/i18n/locales/ja/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "実行中", "completed": "完了", - "error": "エラー" + "error": "エラー", + "imageCountTooltip": "レスポンスに{{count}}個の画像" }, "imageSettings": { "maxImagesLabel": "応答あたりの最大画像数", diff --git a/webview-ui/src/i18n/locales/ko/common.json b/webview-ui/src/i18n/locales/ko/common.json index da90bf11b92..58f3f7eb21f 100644 --- a/webview-ui/src/i18n/locales/ko/common.json +++ b/webview-ui/src/i18n/locales/ko/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}}개월 전", "year_ago": "1년 전", "years_ago": "{{count}}년 전" + }, + "thumbnails": { + "failedToLoad": "이미지를 불러오지 못했습니다", + "altText": "썸네일 {{index}}" } } diff --git a/webview-ui/src/i18n/locales/ko/mcp.json b/webview-ui/src/i18n/locales/ko/mcp.json index e392e82b38d..f927b8c9d8a 100644 --- a/webview-ui/src/i18n/locales/ko/mcp.json +++ b/webview-ui/src/i18n/locales/ko/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "실행 중", "completed": "완료됨", - "error": "오류" + "error": "오류", + "imageCountTooltip": "응답에 {{count}}개의 이미지" }, "imageSettings": { "maxImagesLabel": "응답당 최대 이미지 수", diff --git a/webview-ui/src/i18n/locales/nl/common.json b/webview-ui/src/i18n/locales/nl/common.json index 1fb09ee41a0..b0137e2e42c 100644 --- a/webview-ui/src/i18n/locales/nl/common.json +++ b/webview-ui/src/i18n/locales/nl/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} maanden geleden", "year_ago": "een jaar geleden", "years_ago": "{{count}} jaar geleden" + }, + "thumbnails": { + "failedToLoad": "Afbeelding laden mislukt", + "altText": "Thumbnail {{index}}" } } diff --git a/webview-ui/src/i18n/locales/nl/mcp.json b/webview-ui/src/i18n/locales/nl/mcp.json index c49416d7bcf..39ab6a3692a 100644 --- a/webview-ui/src/i18n/locales/nl/mcp.json +++ b/webview-ui/src/i18n/locales/nl/mcp.json @@ -61,7 +61,8 @@ "execution": { "running": "Wordt uitgevoerd", "completed": "Voltooid", - "error": "Fout" + "error": "Fout", + "imageCountTooltip": "{{count}} afbeelding(en) in reactie" }, "imageSettings": { "maxImagesLabel": "Max afbeeldingen per antwoord", diff --git a/webview-ui/src/i18n/locales/pl/common.json b/webview-ui/src/i18n/locales/pl/common.json index ea6ada357de..f906192941d 100644 --- a/webview-ui/src/i18n/locales/pl/common.json +++ b/webview-ui/src/i18n/locales/pl/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} miesięcy temu", "year_ago": "rok temu", "years_ago": "{{count}} lat temu" + }, + "thumbnails": { + "failedToLoad": "Nie udało się załadować obrazu", + "altText": "Miniatura {{index}}" } } diff --git a/webview-ui/src/i18n/locales/pl/mcp.json b/webview-ui/src/i18n/locales/pl/mcp.json index ddc1d6d4016..74ca70683b7 100644 --- a/webview-ui/src/i18n/locales/pl/mcp.json +++ b/webview-ui/src/i18n/locales/pl/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "Uruchomione", "completed": "Zakończone", - "error": "Błąd" + "error": "Błąd", + "imageCountTooltip": "{{count}} obraz(ów) w odpowiedzi" }, "imageSettings": { "maxImagesLabel": "Maksymalna liczba obrazów na odpowiedź", diff --git a/webview-ui/src/i18n/locales/pt-BR/common.json b/webview-ui/src/i18n/locales/pt-BR/common.json index 1528567c9a0..d08d29be3a5 100644 --- a/webview-ui/src/i18n/locales/pt-BR/common.json +++ b/webview-ui/src/i18n/locales/pt-BR/common.json @@ -95,5 +95,9 @@ "months_ago": "há {{count}} meses", "year_ago": "há um ano", "years_ago": "há {{count}} anos" + }, + "thumbnails": { + "failedToLoad": "Falha ao carregar imagem", + "altText": "Miniatura {{index}}" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/mcp.json b/webview-ui/src/i18n/locales/pt-BR/mcp.json index 771cd7a37bd..7844bf95a07 100644 --- a/webview-ui/src/i18n/locales/pt-BR/mcp.json +++ b/webview-ui/src/i18n/locales/pt-BR/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "Em execução", "completed": "Concluído", - "error": "Erro" + "error": "Erro", + "imageCountTooltip": "{{count}} imagem(ns) na resposta" }, "imageSettings": { "maxImagesLabel": "Máximo de imagens por resposta", diff --git a/webview-ui/src/i18n/locales/ru/common.json b/webview-ui/src/i18n/locales/ru/common.json index cd5ba42c014..1088e4163aa 100644 --- a/webview-ui/src/i18n/locales/ru/common.json +++ b/webview-ui/src/i18n/locales/ru/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} месяцев назад", "year_ago": "год назад", "years_ago": "{{count}} лет назад" + }, + "thumbnails": { + "failedToLoad": "Не удалось загрузить изображение", + "altText": "Миниатюра {{index}}" } } diff --git a/webview-ui/src/i18n/locales/ru/mcp.json b/webview-ui/src/i18n/locales/ru/mcp.json index 227da0b2d8a..fb38af76045 100644 --- a/webview-ui/src/i18n/locales/ru/mcp.json +++ b/webview-ui/src/i18n/locales/ru/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "Выполняется", "completed": "Завершено", - "error": "Ошибка" + "error": "Ошибка", + "imageCountTooltip": "{{count}} изображение(й) в ответе" }, "imageSettings": { "maxImagesLabel": "Максимум изображений в ответе", diff --git a/webview-ui/src/i18n/locales/tr/common.json b/webview-ui/src/i18n/locales/tr/common.json index aa049fc35d1..df274f636ed 100644 --- a/webview-ui/src/i18n/locales/tr/common.json +++ b/webview-ui/src/i18n/locales/tr/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} ay önce", "year_ago": "bir yıl önce", "years_ago": "{{count}} yıl önce" + }, + "thumbnails": { + "failedToLoad": "Resim yüklenemedi", + "altText": "Küçük resim {{index}}" } } diff --git a/webview-ui/src/i18n/locales/tr/mcp.json b/webview-ui/src/i18n/locales/tr/mcp.json index 4a27bfb9d43..401cff5a218 100644 --- a/webview-ui/src/i18n/locales/tr/mcp.json +++ b/webview-ui/src/i18n/locales/tr/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "Çalışıyor", "completed": "Tamamlandı", - "error": "Hata" + "error": "Hata", + "imageCountTooltip": "Yanıtta {{count}} resim" }, "imageSettings": { "maxImagesLabel": "Yanıt Başına Maksimum Görüntü", diff --git a/webview-ui/src/i18n/locales/vi/common.json b/webview-ui/src/i18n/locales/vi/common.json index f9fad7dbc33..c161b99a3c2 100644 --- a/webview-ui/src/i18n/locales/vi/common.json +++ b/webview-ui/src/i18n/locales/vi/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} tháng trước", "year_ago": "một năm trước", "years_ago": "{{count}} năm trước" + }, + "thumbnails": { + "failedToLoad": "Không thể tải hình ảnh", + "altText": "Hình thu nhỏ {{index}}" } } diff --git a/webview-ui/src/i18n/locales/vi/mcp.json b/webview-ui/src/i18n/locales/vi/mcp.json index 9b504537a77..2797e3acd88 100644 --- a/webview-ui/src/i18n/locales/vi/mcp.json +++ b/webview-ui/src/i18n/locales/vi/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "Đang chạy", "completed": "Hoàn thành", - "error": "Lỗi" + "error": "Lỗi", + "imageCountTooltip": "{{count}} hình ảnh trong phản hồi" }, "imageSettings": { "maxImagesLabel": "Số lượng hình ảnh tối đa mỗi phản hồi", diff --git a/webview-ui/src/i18n/locales/zh-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index 8b422be0606..d1b96db7b3f 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}}个月前", "year_ago": "1年前", "years_ago": "{{count}}年前" + }, + "thumbnails": { + "failedToLoad": "加载图片失败", + "altText": "缩略图 {{index}}" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/mcp.json b/webview-ui/src/i18n/locales/zh-CN/mcp.json index df56936e92c..0cb16e7a964 100644 --- a/webview-ui/src/i18n/locales/zh-CN/mcp.json +++ b/webview-ui/src/i18n/locales/zh-CN/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "运行中", "completed": "已完成", - "error": "错误" + "error": "错误", + "imageCountTooltip": "响应中有 {{count}} 张图片" }, "imageSettings": { "maxImagesLabel": "每个响应的最大图像数", diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index 85e4ce53cc1..e3c0d1336cf 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} 個月前", "year_ago": "1 年前", "years_ago": "{{count}} 年前" + }, + "thumbnails": { + "failedToLoad": "載入圖片失敗", + "altText": "縮圖 {{index}}" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/mcp.json b/webview-ui/src/i18n/locales/zh-TW/mcp.json index 3d9661c65cc..6d0087eb695 100644 --- a/webview-ui/src/i18n/locales/zh-TW/mcp.json +++ b/webview-ui/src/i18n/locales/zh-TW/mcp.json @@ -60,7 +60,8 @@ "execution": { "running": "執行中", "completed": "已完成", - "error": "錯誤" + "error": "錯誤", + "imageCountTooltip": "回應中有 {{count}} 張圖片" }, "imageSettings": { "maxImagesLabel": "每個回應的最大圖片數",