From 9619603b1c741645d152af2e6cb4a18cabe1b283 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 17 Sep 2025 07:50:14 +0000 Subject: [PATCH] feat: add custom MCP server installation feature - Add "Add Custom MCP" button to marketplace MCP tab - Create CustomMcpDialog component for configuring custom servers - Include Serena MCP server as example configuration - Implement backend handler for custom MCP installation - Add comprehensive tests for the new functionality - Fix linting issues Fixes #8059 --- .../webviewMessageHandler.customMcp.spec.ts | 251 ++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 75 ++++++ src/shared/WebviewMessage.ts | 2 + .../marketplace/MarketplaceListView.tsx | 29 +- .../components/CustomMcpDialog.tsx | 188 +++++++++++++ .../__tests__/CustomMcpDialog.spec.tsx | 232 ++++++++++++++++ webview-ui/src/components/ui/alert.tsx | 42 +++ webview-ui/src/components/ui/label.tsx | 20 ++ .../src/i18n/locales/en/marketplace.json | 17 ++ 9 files changed, 855 insertions(+), 1 deletion(-) create mode 100644 src/core/webview/__tests__/webviewMessageHandler.customMcp.spec.ts create mode 100644 webview-ui/src/components/marketplace/components/CustomMcpDialog.tsx create mode 100644 webview-ui/src/components/marketplace/components/__tests__/CustomMcpDialog.spec.tsx create mode 100644 webview-ui/src/components/ui/alert.tsx create mode 100644 webview-ui/src/components/ui/label.tsx diff --git a/src/core/webview/__tests__/webviewMessageHandler.customMcp.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.customMcp.spec.ts new file mode 100644 index 0000000000..72e1133367 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.customMcp.spec.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { webviewMessageHandler } from "../webviewMessageHandler" +import * as vscode from "vscode" +import * as fs from "fs/promises" +import * as path from "path" + +// Mock vscode +vi.mock("vscode", () => ({ + window: { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + }, + workspace: { + workspaceFolders: [], + }, +})) + +// Mock fs/promises +vi.mock("fs/promises", () => ({ + default: {}, + mkdir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), +})) + +// Mock safeWriteJson +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn(), +})) + +// Mock openFile +vi.mock("../../../integrations/misc/open-file", () => ({ + openFile: vi.fn(), +})) + +// Mock i18n +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), +})) + +describe("webviewMessageHandler - addCustomMcpServer", () => { + let mockProvider: any + let mockMcpHub: any + + beforeEach(() => { + vi.clearAllMocks() + + mockMcpHub = { + getMcpSettingsFilePath: vi.fn().mockResolvedValue("/mock/global/mcp.json"), + refreshAllConnections: vi.fn().mockResolvedValue(undefined), + } + + mockProvider = { + getMcpHub: vi.fn().mockReturnValue(mockMcpHub), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + contextProxy: { + getValue: vi.fn(), + setValue: vi.fn().mockResolvedValue(undefined), + }, + } + }) + + it("should add custom MCP server to project settings when workspace is available", async () => { + // Setup workspace + vi.mocked(vscode.workspace).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } } as any] + + // Mock fs operations + vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found")) // Simulate no existing file + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + + const message = { + type: "addCustomMcpServer" as const, + serverName: "serena-mcp", + customMcpConfig: { + command: "npx", + args: ["-y", "@serena/mcp-server"], + env: { NODE_ENV: "production" }, + }, + } + + // Mock getCurrentTask to return null (no active task) + mockProvider.getCurrentTask = vi.fn().mockReturnValue({ + cwd: "/test/workspace", + }) + mockProvider.cwd = "/test/workspace" + + await webviewMessageHandler(mockProvider, message) + + // Verify directory creation + expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining(".roo"), { recursive: true }) + + // Verify MCP settings were written + const { safeWriteJson } = await import("../../../utils/safeWriteJson") + expect(safeWriteJson).toHaveBeenCalledWith( + expect.stringContaining("mcp.json"), + expect.objectContaining({ + mcpServers: { + "serena-mcp": { + command: "npx", + args: ["-y", "@serena/mcp-server"], + env: { NODE_ENV: "production" }, + }, + }, + }), + ) + + // Verify MCP hub refresh + expect(mockMcpHub.refreshAllConnections).toHaveBeenCalled() + + // Verify success message + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("marketplace:customMcp.success"), + ) + + // Verify state update + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("should add custom MCP server to global settings when no workspace is available", async () => { + // No workspace + vi.mocked(vscode.workspace).workspaceFolders = undefined + + // Mock existing MCP settings + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + mcpServers: { + "existing-server": { + command: "existing", + args: [], + }, + }, + }), + ) + + const message = { + type: "addCustomMcpServer" as const, + serverName: "new-server", + customMcpConfig: { + command: "new-command", + args: ["arg1", "arg2"], + }, + } + + await webviewMessageHandler(mockProvider, message) + + // Verify global settings path was used + expect(mockMcpHub.getMcpSettingsFilePath).toHaveBeenCalled() + + // Verify MCP settings were merged + const { safeWriteJson } = await import("../../../utils/safeWriteJson") + expect(safeWriteJson).toHaveBeenCalledWith( + "/mock/global/mcp.json", + expect.objectContaining({ + mcpServers: { + "existing-server": { + command: "existing", + args: [], + }, + "new-server": { + command: "new-command", + args: ["arg1", "arg2"], + }, + }, + }), + ) + }) + + it("should show error when MCP hub is not available", async () => { + mockProvider.getMcpHub.mockReturnValue(null) + + const message = { + type: "addCustomMcpServer" as const, + serverName: "test-server", + customMcpConfig: { + command: "test", + args: [], + }, + } + + await webviewMessageHandler(mockProvider, message) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("mcp:errors.hub_not_available"), + ) + }) + + it("should show error when server name is missing", async () => { + const message = { + type: "addCustomMcpServer" as const, + serverName: "", + customMcpConfig: { + command: "test", + args: [], + }, + } + + await webviewMessageHandler(mockProvider, message) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("marketplace:customMcp.error"), + ) + }) + + it("should show error when config is missing", async () => { + const message = { + type: "addCustomMcpServer" as const, + serverName: "test-server", + customMcpConfig: null as any, + } + + await webviewMessageHandler(mockProvider, message) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("marketplace:customMcp.error"), + ) + }) + + it("should handle errors during MCP server addition", async () => { + vi.mocked(vscode.workspace).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } } as any] + + // Mock getCurrentTask to return task with cwd + mockProvider.getCurrentTask = vi.fn().mockReturnValue({ + cwd: "/test/workspace", + }) + mockProvider.cwd = "/test/workspace" + + // Mock fs.mkdir to throw an error + const testError = new Error("Permission denied") + vi.mocked(fs.mkdir).mockRejectedValue(testError) + + const message = { + type: "addCustomMcpServer" as const, + serverName: "test-server", + customMcpConfig: { + command: "test", + args: [], + }, + } + + await webviewMessageHandler(mockProvider, message) + + // Verify error logging + expect(mockProvider.log).toHaveBeenCalledWith(expect.stringContaining("Failed to add custom MCP server")) + + // Verify error message - the actual error message includes the error details + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringMatching(/Permission denied|marketplace:customMcp\.error/), + ) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index abdfae29fa..c9a0646806 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3044,5 +3044,80 @@ export const webviewMessageHandler = async ( }) break } + case "addCustomMcpServer": { + if (!message.serverName || !message.customMcpConfig) { + vscode.window.showErrorMessage( + t("marketplace:customMcp.error") || "Invalid custom MCP server configuration", + ) + break + } + + try { + // Get the MCP hub instance + const mcpHub = provider.getMcpHub() + if (!mcpHub) { + vscode.window.showErrorMessage(t("mcp:errors.hub_not_available") || "MCP hub is not available") + break + } + + // Determine the target (project or global) + const target = vscode.workspace.workspaceFolders?.length ? "project" : "global" + + // Get the appropriate MCP settings file path + let mcpSettingsPath: string + if (target === "project") { + const workspaceFolder = getCurrentCwd() + const rooDir = path.join(workspaceFolder, ".roo") + mcpSettingsPath = path.join(rooDir, "mcp.json") + + // Ensure .roo directory exists + await fs.mkdir(rooDir, { recursive: true }) + } else { + // Global settings + mcpSettingsPath = (await mcpHub.getMcpSettingsFilePath()) || "" + } + + // Read existing MCP settings or create new + let mcpSettings: any = { mcpServers: {} } + try { + const existingContent = await fs.readFile(mcpSettingsPath, "utf-8") + mcpSettings = JSON.parse(existingContent) + if (!mcpSettings.mcpServers) { + mcpSettings.mcpServers = {} + } + } catch (error) { + // File doesn't exist or is invalid, use default + } + + // Add the new custom MCP server + mcpSettings.mcpServers[message.serverName] = message.customMcpConfig + + // Write the updated settings + await safeWriteJson(mcpSettingsPath, mcpSettings) + + // Refresh MCP connections to load the new server + await mcpHub.refreshAllConnections() + + // Show success message + vscode.window.showInformationMessage( + t("marketplace:customMcp.success") || + `Custom MCP server "${message.serverName}" added successfully`, + ) + + // Update the webview state + await provider.postStateToWebview() + + // Open the MCP settings file to show the new server + await openFile(mcpSettingsPath) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Failed to add custom MCP server: ${errorMessage}`) + vscode.window.showErrorMessage( + t("marketplace:customMcp.error", { error: errorMessage }) || + `Failed to add custom MCP server: ${errorMessage}`, + ) + } + break + } } } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 93d0b9bc45..acf158e4e5 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -225,6 +225,7 @@ export interface WebviewMessage { | "editQueuedMessage" | "dismissUpsell" | "getDismissedUpsells" + | "addCustomMcpServer" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" @@ -268,6 +269,7 @@ export interface WebviewMessage { mpInstallOptions?: InstallMarketplaceItemOptions config?: Record // Add config to the payload visibility?: ShareVisibility // For share visibility + customMcpConfig?: { command: string; args?: string[]; env?: Record } // For custom MCP server hasContent?: boolean // For checkRulesDirectoryResult checkOnly?: boolean // For deleteCustomMode check upsellId?: string // For dismissUpsell diff --git a/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/webview-ui/src/components/marketplace/MarketplaceListView.tsx index c3c497ccfe..2600c71d8c 100644 --- a/webview-ui/src/components/marketplace/MarketplaceListView.tsx +++ b/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -4,13 +4,14 @@ import { Button } from "@/components/ui/button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" -import { X, ChevronsUpDown } from "lucide-react" +import { X, ChevronsUpDown, Plus } from "lucide-react" import { MarketplaceItemCard } from "./components/MarketplaceItemCard" import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager" import { useAppTranslation } from "@/i18n/TranslationContext" import { useStateManager } from "./useStateManager" import { useExtensionState } from "@/context/ExtensionStateContext" import { IssueFooter } from "./IssueFooter" +import { CustomMcpDialog } from "./components/CustomMcpDialog" export interface MarketplaceListViewProps { stateManager: MarketplaceViewStateManager @@ -25,6 +26,7 @@ export function MarketplaceListView({ stateManager, allTags, filteredTags, filte const { marketplaceInstalledMetadata, cloudUserInfo } = useExtensionState() const [isTagPopoverOpen, setIsTagPopoverOpen] = React.useState(false) const [tagSearch, setTagSearch] = React.useState("") + const [showCustomMcpDialog, setShowCustomMcpDialog] = React.useState(false) const allItems = state.displayItems || [] const organizationMcps = state.displayOrganizationMcps || [] @@ -39,7 +41,32 @@ export function MarketplaceListView({ stateManager, allTags, filteredTags, filte return ( <> + {/* Custom MCP Dialog */} + {showCustomMcpDialog && ( + setShowCustomMcpDialog(false)} + onSuccess={() => { + setShowCustomMcpDialog(false) + // Optionally refresh the marketplace data + manager.transition({ type: "REFRESH" }) + }} + /> + )} +
+ {/* Add Custom MCP Button for MCP tab */} + {filterByType === "mcp" && ( +
+ +
+ )}
void + onSuccess: () => void +} + +export function CustomMcpDialog({ onClose, onSuccess }: CustomMcpDialogProps) { + const { t } = useAppTranslation() + const [serverName, setServerName] = useState("") + const [command, setCommand] = useState("") + const [args, setArgs] = useState("") + const [env, setEnv] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + const [showSerenaExample, setShowSerenaExample] = useState(false) + + const handleSubmit = async () => { + // Validate inputs + if (!serverName.trim()) { + setError("Server name is required") + return + } + if (!command.trim()) { + setError("Command is required") + return + } + + setIsSubmitting(true) + setError(null) + + try { + // Parse arguments + const argsList = args + .split(",") + .map((arg) => arg.trim()) + .filter((arg) => arg.length > 0) + + // Parse environment variables + const envObj: Record = {} + if (env.trim()) { + const envLines = env.split("\n") + for (const line of envLines) { + const trimmedLine = line.trim() + if (trimmedLine && trimmedLine.includes("=")) { + const [key, ...valueParts] = trimmedLine.split("=") + envObj[key.trim()] = valueParts.join("=").trim() + } + } + } + + // Create the MCP server configuration + const mcpConfig = { + command: command.trim(), + args: argsList, + ...(Object.keys(envObj).length > 0 && { env: envObj }), + } + + // Send message to add custom MCP server + vscode.postMessage({ + type: "addCustomMcpServer", + serverName: serverName.trim(), + customMcpConfig: mcpConfig, + }) + + // Success - close dialog and notify parent + onSuccess() + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to add custom MCP server") + } finally { + setIsSubmitting(false) + } + } + + const loadSerenaExample = () => { + setServerName("serena-mcp") + setCommand("npx") + setArgs("-y, @serena/mcp-server") + setEnv("") + setShowSerenaExample(false) + } + + return ( + !open && onClose()}> + + + {t("marketplace:customMcp.title")} + {t("marketplace:customMcp.description")} + + +
+ {showSerenaExample && ( + + + +
+

Looking to add Serena MCP server? Here's an example configuration:

+ +
+
+
+ )} + +
+ + setServerName(e.target.value)} + placeholder={t("marketplace:customMcp.serverNamePlaceholder")} + disabled={isSubmitting} + /> + {serverName === "" && ( + + )} +
+ +
+ + setCommand(e.target.value)} + placeholder={t("marketplace:customMcp.commandPlaceholder")} + disabled={isSubmitting} + /> +
+ +
+ + setArgs(e.target.value)} + placeholder={t("marketplace:customMcp.argsPlaceholder")} + disabled={isSubmitting} + /> +

Separate multiple arguments with commas

+
+ +
+ +