diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index c071726d8a5..c1bb5a317a9 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -133,6 +133,7 @@ export const globalSettingsSchema = z.object({ mcpEnabled: z.boolean().optional(), enableMcpServerCreation: z.boolean().optional(), + mcpResponseSizeThreshold: z.number().optional(), remoteControlEnabled: z.boolean().optional(), diff --git a/src/core/tools/accessMcpResourceTool.ts b/src/core/tools/accessMcpResourceTool.ts index c8a40f9236d..679fb590b47 100644 --- a/src/core/tools/accessMcpResourceTool.ts +++ b/src/core/tools/accessMcpResourceTool.ts @@ -2,6 +2,7 @@ import { ClineAskUseMcpServer } from "../../shared/ExtensionMessage" import { ToolUse, RemoveClosingTag, AskApproval, HandleError, PushToolResult } from "../../shared/tools" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" +import { defaultMcpResponseHandler } from "../../utils/mcpResponseHandler" export async function accessMcpResourceTool( cline: Task, @@ -57,7 +58,7 @@ export async function accessMcpResourceTool( await cline.say("mcp_server_request_started") const resourceResult = await cline.providerRef.deref()?.getMcpHub()?.readResource(server_name, uri) - const resourceResultPretty = + const resourceResultText = resourceResult?.contents .map((item) => { if (item.text) { @@ -81,6 +82,16 @@ export async function accessMcpResourceTool( } }) + // Check if response is large and should be saved to file + const processedResponse = await defaultMcpResponseHandler.processResponse( + resourceResultText, + server_name, + uri, + ) + + // Use the processed content (either original or file reference) + const resourceResultPretty = processedResponse.content + await cline.say("mcp_server_response", resourceResultPretty, images) pushToolResult(formatResponse.toolResult(resourceResultPretty, images)) diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/useMcpToolTool.ts index 30dff5ce4fa..b5b59b2a0cc 100644 --- a/src/core/tools/useMcpToolTool.ts +++ b/src/core/tools/useMcpToolTool.ts @@ -4,6 +4,7 @@ import { formatResponse } from "../prompts/responses" import { ClineAskUseMcpServer } from "../../shared/ExtensionMessage" import { McpExecutionStatus } from "@roo-code/types" import { t } from "../../i18n" +import { defaultMcpResponseHandler } from "../../utils/mcpResponseHandler" interface McpToolParams { server_name?: string @@ -135,13 +136,18 @@ async function executeToolAndProcessResult( const outputText = processToolContent(toolResult) if (outputText) { + // Check if response is large and should be saved to file + const processedResponse = await defaultMcpResponseHandler.processResponse(outputText, serverName, toolName) + await sendExecutionStatus(cline, { executionId, status: "output", - response: outputText, + response: processedResponse.savedToFile + ? `Response saved to file: ${processedResponse.filePath}` + : outputText, }) - toolResultPretty = (toolResult.isError ? "Error:\n" : "") + outputText + toolResultPretty = (toolResult.isError ? "Error:\n" : "") + processedResponse.content } // Send completion status diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 81c0a562c77..f21b35fd2ca 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1701,6 +1701,7 @@ export class ClineProvider maxDiagnosticMessages, includeTaskHistoryInEnhance, remoteControlEnabled, + mcpResponseSizeThreshold, } = await this.getState() const telemetryKey = process.env.POSTHOG_API_KEY @@ -1829,6 +1830,7 @@ export class ClineProvider maxDiagnosticMessages: maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? false, remoteControlEnabled: remoteControlEnabled ?? false, + mcpResponseSizeThreshold: mcpResponseSizeThreshold ?? 50000, } } @@ -2018,6 +2020,8 @@ export class ClineProvider includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? false, // Add remoteControlEnabled setting remoteControlEnabled: stateValues.remoteControlEnabled ?? false, + // Add MCP response size threshold setting + mcpResponseSizeThreshold: stateValues.mcpResponseSizeThreshold ?? 50000, } } diff --git a/src/utils/mcpResponseHandler.test.ts b/src/utils/mcpResponseHandler.test.ts new file mode 100644 index 00000000000..e36a7e64201 --- /dev/null +++ b/src/utils/mcpResponseHandler.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest" +import * as fs from "fs/promises" +import * as os from "os" + +// Mock the modules before importing the module under test +vi.mock("fs/promises", () => ({ + mkdir: vi.fn(), + writeFile: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + unlink: vi.fn(), +})) +vi.mock("os", () => ({ + tmpdir: vi.fn(() => "/mock/tmp/dir"), +})) +vi.mock("./safeWriteJson", () => ({ + safeWriteJson: vi.fn(), +})) + +// Import after mocks are set up +const { McpResponseHandler } = await import("./mcpResponseHandler") +const { safeWriteJson } = await import("./safeWriteJson") + +describe("McpResponseHandler", () => { + let handler: InstanceType + const mockTmpDir = "/mock/tmp/dir" + const mockResponseDir = `${mockTmpDir}/roo-code-mcp-responses` + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + vi.mocked(safeWriteJson).mockResolvedValue(undefined) + + // Mock Date for consistent file naming + vi.spyOn(Date.prototype, "toISOString").mockReturnValue("2024-01-01T00:00:00.000Z") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("with default threshold", () => { + beforeEach(() => { + handler = new McpResponseHandler() + }) + + it("should return small responses directly without saving to file", async () => { + const smallResponse = "Small response content" + const result = await handler.processResponse(smallResponse, "testServer", "testTool") + + expect(result.savedToFile).toBe(false) + expect(result.content).toBe(smallResponse) + expect(result.filePath).toBeUndefined() + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + it("should save large responses to file and return preview", async () => { + const largeResponse = "x".repeat(60000) // 60KB response + const result = await handler.processResponse(largeResponse, "testServer", "testTool") + + expect(result.savedToFile).toBe(true) + expect(result.filePath).toBeDefined() + expect(result.content).toContain("[MCP Response saved to file due to large size") + expect(result.content).toContain("File:") + expect(result.content).toContain("Preview of response:") + // Check that preview shows limited content + const lines = result.content.split("\n") + const previewStartIndex = lines.findIndex((line: string) => line.includes("Preview of response:")) + expect(previewStartIndex).toBeGreaterThan(-1) + + expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining("roo-code-mcp-responses"), { + recursive: true, + }) + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining("mcp-response-testServer-testTool"), + largeResponse, + "utf-8", + ) + }) + + it("should handle JSON responses using processStructuredResponse", async () => { + const jsonData = { + data: "x".repeat(50000), + metadata: { count: 100 }, + } + const result = await handler.processStructuredResponse(jsonData, "dbServer", "queryTool") + + expect(result.savedToFile).toBe(true) + expect(result.content).toContain("Preview of response:") + expect(result.content).toContain('"data"') + expect(result.content).toContain('"metadata"') + + expect(safeWriteJson).toHaveBeenCalledWith( + expect.stringContaining("mcp-response-dbServer-queryTool"), + jsonData, + ) + }) + + it("should handle non-JSON responses in preview", async () => { + const textResponse = "Plain text response\n".repeat(3000) // Large plain text + const result = await handler.processResponse(textResponse, "textServer", "textTool") + + expect(result.savedToFile).toBe(true) + expect(result.content).toContain("Preview of response:") + expect(result.content).toContain("Plain text response") + }) + + it("should handle file write errors gracefully", async () => { + vi.mocked(fs.writeFile).mockRejectedValue(new Error("Write failed")) + + const largeResponse = "x".repeat(60000) + await expect(handler.processResponse(largeResponse, "testServer", "testTool")).rejects.toThrow( + "Write failed", + ) + }) + + it("should create directory if it doesn't exist", async () => { + const largeResponse = "x".repeat(60000) + await handler.processResponse(largeResponse, "testServer", "testTool") + + expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining("roo-code-mcp-responses"), { + recursive: true, + }) + }) + }) + + describe("with custom threshold", () => { + it("should use custom maxResponseSize when provided", async () => { + handler = new McpResponseHandler({ maxResponseSize: 1000 }) // 1KB threshold + + const smallResponse = "x".repeat(500) // 500 bytes - under threshold + const result1 = await handler.processResponse(smallResponse, "server", "tool") + expect(result1.savedToFile).toBe(false) + + const largeResponse = "x".repeat(1500) // 1.5KB - over threshold + const result2 = await handler.processResponse(largeResponse, "server", "tool") + expect(result2.savedToFile).toBe(true) + }) + }) + + describe("edge cases", () => { + beforeEach(() => { + handler = new McpResponseHandler() + }) + + it("should handle empty responses", async () => { + const result = await handler.processResponse("", "server", "tool") + expect(result.savedToFile).toBe(false) + expect(result.content).toBe("") + }) + + it("should handle responses exactly at threshold", async () => { + const maxResponseSize = 50 * 1024 // 50KB + handler = new McpResponseHandler({ maxResponseSize }) + + const response = "x".repeat(maxResponseSize) + const result = await handler.processResponse(response, "server", "tool") + expect(result.savedToFile).toBe(false) // Should NOT save when exactly at threshold (<=) + + const largerResponse = "x".repeat(maxResponseSize + 1) + const result2 = await handler.processResponse(largerResponse, "server", "tool") + expect(result2.savedToFile).toBe(true) // Should save when over threshold + }) + + it("should handle special characters in server and tool names", async () => { + const largeResponse = "x".repeat(60000) + const result = await handler.processResponse( + largeResponse, + "server-with-slashes", + "tool-with-dashes_and_underscores", + ) + + expect(result.savedToFile).toBe(true) + expect(result.filePath).toContain("server-with-slashes") + expect(result.filePath).toContain("tool-with-dashes_and_underscores") + expect(result.content).toContain("[MCP Response saved to file") + }) + + it("should limit preview to configured number of lines", async () => { + const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`) + const response = lines.join("\n") + handler = new McpResponseHandler({ + maxResponseSize: 100, // Low threshold to trigger save + previewLines: 10, // Only show 10 lines in preview + }) + + const result = await handler.processResponse(response, "server", "tool") + expect(result.savedToFile).toBe(true) + + // Check that preview contains first 10 lines + expect(result.content).toContain("Line 1") + expect(result.content).toContain("Line 10") + expect(result.content).not.toContain("Line 11") + expect(result.content).toContain("... (90 more lines)") + }) + + describe("cleanupOldFiles", () => { + beforeEach(() => { + handler = new McpResponseHandler() + }) + + it("should delete old MCP response files", async () => { + const mockFiles = ["mcp-response-old-file.txt", "mcp-response-recent-file.txt", "other-file.txt"] + + const oldDate = new Date(Date.now() - 25 * 60 * 60 * 1000) // 25 hours ago + const recentDate = new Date(Date.now() - 1 * 60 * 60 * 1000) // 1 hour ago + + const readdir = vi.mocked(fs.readdir) + const stat = vi.mocked(fs.stat) + const unlink = vi.mocked(fs.unlink) + + readdir.mockResolvedValue(mockFiles as any) + stat.mockImplementation(async (filePath) => { + const pathStr = String(filePath) + if (pathStr.includes("old-file")) { + return { mtime: oldDate } as any + } + return { mtime: recentDate } as any + }) + unlink.mockResolvedValue(undefined) + + const deletedCount = await handler.cleanupOldFiles(24) + + expect(deletedCount).toBe(1) + expect(unlink).toHaveBeenCalledWith(expect.stringContaining("mcp-response-old-file.txt")) + expect(unlink).not.toHaveBeenCalledWith(expect.stringContaining("recent-file")) + expect(unlink).not.toHaveBeenCalledWith(expect.stringContaining("other-file")) + }) + + it("should handle missing directory gracefully", async () => { + const readdir = vi.mocked(fs.readdir) + readdir.mockRejectedValue(new Error("ENOENT")) + + const deletedCount = await handler.cleanupOldFiles(24) + + expect(deletedCount).toBe(0) + }) + }) + }) +}) diff --git a/src/utils/mcpResponseHandler.ts b/src/utils/mcpResponseHandler.ts new file mode 100644 index 00000000000..14230a17eb6 --- /dev/null +++ b/src/utils/mcpResponseHandler.ts @@ -0,0 +1,232 @@ +import * as fs from "fs/promises" +import * as path from "path" +import * as os from "os" +import crypto from "crypto" +import { safeWriteJson } from "./safeWriteJson" + +/** + * Configuration for MCP response handling + */ +export interface McpResponseConfig { + /** Maximum size in characters before saving to file (default: 50000 ~= 50KB of text) */ + maxResponseSize?: number + /** Directory to save large responses (default: temp directory) */ + responseDirectory?: string + /** Whether to include a preview in the context when saving to file (default: true) */ + includePreview?: boolean + /** Number of lines to include in preview (default: 50) */ + previewLines?: number +} + +const getDefaultResponseDirectory = () => path.join(os.tmpdir(), "roo-code-mcp-responses") + +const DEFAULT_CONFIG: Required = { + maxResponseSize: 50000, // ~50KB of text + responseDirectory: "", // Will be set dynamically + includePreview: true, + previewLines: 50, +} + +/** + * Handles MCP responses, saving large ones to files to avoid context window issues + */ +export class McpResponseHandler { + private config: Required + + constructor(config?: McpResponseConfig) { + this.config = { + ...DEFAULT_CONFIG, + responseDirectory: config?.responseDirectory || getDefaultResponseDirectory(), + ...config, + } + } + + /** + * Process an MCP response, saving to file if it exceeds the size threshold + * @param response The MCP response content + * @param serverName The name of the MCP server + * @param toolOrResourceName The name of the tool or resource + * @returns The processed response (either original or file reference with preview) + */ + async processResponse( + response: string, + serverName: string, + toolOrResourceName: string, + ): Promise<{ content: string; savedToFile: boolean; filePath?: string }> { + // Check if response exceeds threshold + if (response.length <= this.config.maxResponseSize) { + return { + content: response, + savedToFile: false, + } + } + + // Generate unique filename + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const hash = crypto.createHash("md5").update(response).digest("hex").substring(0, 8) + const filename = `mcp-response-${serverName}-${toolOrResourceName}-${timestamp}-${hash}.txt` + const filePath = path.join(this.config.responseDirectory, filename) + + // Ensure directory exists + await fs.mkdir(this.config.responseDirectory, { recursive: true }) + + // Save response to file + await fs.writeFile(filePath, response, "utf-8") + + // Create preview if configured + let preview = "" + if (this.config.includePreview) { + const lines = response.split("\n") + const previewLines = lines.slice(0, this.config.previewLines) + preview = previewLines.join("\n") + + if (lines.length > this.config.previewLines) { + preview += `\n\n... (${lines.length - this.config.previewLines} more lines)` + } + } + + // Format the file reference message + const fileReference = this.formatFileReference(filePath, response.length, preview) + + return { + content: fileReference, + savedToFile: true, + filePath, + } + } + + /** + * Process an MCP response that may contain structured data (JSON) + * @param responseData The MCP response data (could be object or string) + * @param serverName The name of the MCP server + * @param toolOrResourceName The name of the tool or resource + * @returns The processed response + */ + async processStructuredResponse( + responseData: any, + serverName: string, + toolOrResourceName: string, + ): Promise<{ content: string; savedToFile: boolean; filePath?: string }> { + // Convert to string if needed + let responseStr: string + if (typeof responseData === "string") { + responseStr = responseData + } else { + responseStr = JSON.stringify(responseData, null, 2) + } + + // Check if response exceeds threshold + if (responseStr.length <= this.config.maxResponseSize) { + return { + content: responseStr, + savedToFile: false, + } + } + + // Generate unique filename for JSON data + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const hash = crypto.createHash("md5").update(responseStr).digest("hex").substring(0, 8) + const filename = `mcp-response-${serverName}-${toolOrResourceName}-${timestamp}-${hash}.json` + const filePath = path.join(this.config.responseDirectory, filename) + + // Ensure directory exists + await fs.mkdir(this.config.responseDirectory, { recursive: true }) + + // Save response to file using safeWriteJson for structured data + if (typeof responseData === "object") { + await safeWriteJson(filePath, responseData) + } else { + await fs.writeFile(filePath, responseStr, "utf-8") + } + + // Create preview + let preview = "" + if (this.config.includePreview) { + const lines = responseStr.split("\n") + const previewLines = lines.slice(0, this.config.previewLines) + preview = previewLines.join("\n") + + if (lines.length > this.config.previewLines) { + preview += `\n\n... (${lines.length - this.config.previewLines} more lines)` + } + } + + // Format the file reference message + const fileReference = this.formatFileReference(filePath, responseStr.length, preview) + + return { + content: fileReference, + savedToFile: true, + filePath, + } + } + + /** + * Format a file reference message for the AI context + */ + private formatFileReference(filePath: string, originalSize: number, preview: string): string { + const sizeKB = Math.round(originalSize / 1024) + + let message = `[MCP Response saved to file due to large size (${sizeKB}KB)]\n` + message += `File: ${filePath}\n` + message += `\n` + + if (preview) { + message += `Preview of response:\n` + message += `${"=".repeat(50)}\n` + message += preview + message += `\n${"=".repeat(50)}\n` + } + + message += `\n` + message += `To work with this data, you can:\n` + message += `1. Use read_file to read the full content: ${filePath}\n` + message += `2. Use execute_command with tools like grep, jq, or custom scripts to process the data\n` + message += `3. Use write_to_file to create scripts that analyze the data\n` + + return message + } + + /** + * Clean up old response files (optional maintenance method) + */ + async cleanupOldFiles(maxAgeHours: number = 24): Promise { + try { + const files = await fs.readdir(this.config.responseDirectory) + const now = Date.now() + const maxAgeMs = maxAgeHours * 60 * 60 * 1000 + let deletedCount = 0 + + for (const file of files) { + if (file.startsWith("mcp-response-")) { + const filePath = path.join(this.config.responseDirectory, file) + const stats = await fs.stat(filePath) + + if (now - stats.mtime.getTime() > maxAgeMs) { + await fs.unlink(filePath) + deletedCount++ + } + } + } + + return deletedCount + } catch (error) { + // Directory might not exist yet + return 0 + } + } +} + +// Export a function to get default instance with default configuration +export const getDefaultMcpResponseHandler = (() => { + let instance: McpResponseHandler | null = null + return () => { + if (!instance) { + instance = new McpResponseHandler() + } + return instance + } +})() + +// For backward compatibility +export const defaultMcpResponseHandler = getDefaultMcpResponseHandler()