From e6db23b87cdcd989b6ff025b8af941c36fba3b71 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 14 May 2026 04:21:51 +0000 Subject: [PATCH] feat: add MCP marketplace with free search engine servers (DuckDuckGo, SearXNG) Adds a "Quick Add Servers" section to the MCP settings view that lets users install pre-configured free search engine MCP servers with one click: - DuckDuckGo Search (via uvx duckduckgo-mcp-server) - no API key needed - SearXNG (via npx mcp-searxng) - requires self-hosted instance URL - Web Search (via npx web-search-mcp) - no API key needed Implementation: - New mcpMarketplaceCatalog with extensible server templates - McpHub.addServer() method for programmatic server installation - installMcpServer webview message type and handler - McpMarketplace UI component with install buttons and env config - i18n translations for marketplace strings - Tests for catalog validation and UI component Closes #12361 --- packages/types/src/vscode-extension-host.ts | 1 + src/core/webview/webviewMessageHandler.ts | 18 ++ src/i18n/locales/en/mcp.json | 4 + src/services/mcp/McpHub.ts | 50 ++++++ .../__tests__/mcpMarketplaceCatalog.spec.ts | 49 ++++++ src/shared/mcpMarketplaceCatalog.ts | 76 +++++++++ .../src/components/mcp/McpMarketplace.tsx | 160 ++++++++++++++++++ webview-ui/src/components/mcp/McpView.tsx | 2 + .../mcp/__tests__/McpMarketplace.spec.tsx | 135 +++++++++++++++ webview-ui/src/i18n/locales/en/mcp.json | 15 ++ 10 files changed, 510 insertions(+) create mode 100644 src/shared/__tests__/mcpMarketplaceCatalog.spec.ts create mode 100644 src/shared/mcpMarketplaceCatalog.ts create mode 100644 webview-ui/src/components/mcp/McpMarketplace.tsx create mode 100644 webview-ui/src/components/mcp/__tests__/McpMarketplace.spec.tsx diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index a4ef802efbc..1a0b557b12b 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -451,6 +451,7 @@ export interface WebviewMessage { | "checkpointDiff" | "checkpointRestore" | "deleteMcpServer" + | "installMcpServer" | "codebaseIndexEnabled" | "searchFiles" | "toggleApiConfigPin" diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index fac7ed10d57..aca8c5ae33f 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1299,6 +1299,24 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We } break } + case "installMcpServer": { + if (!message.serverName || !message.config) { + break + } + + try { + provider.log(`Attempting to install MCP server from marketplace: ${message.serverName}`) + await provider.getMcpHub()?.addServer(message.serverName, message.config) + provider.log(`Successfully installed MCP server: ${message.serverName}`) + + // Refresh the webview state + await provider.postStateToWebview() + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Failed to install MCP server: ${errorMessage}`) + } + break + } case "restartMcpServer": { try { await provider.getMcpHub()?.restartConnection(message.text!, message.source as "global" | "project") diff --git a/src/i18n/locales/en/mcp.json b/src/i18n/locales/en/mcp.json index 0200e26d223..b9bcd2f7c71 100644 --- a/src/i18n/locales/en/mcp.json +++ b/src/i18n/locales/en/mcp.json @@ -24,5 +24,9 @@ "refreshing_all": "Refreshing all MCP servers...", "all_refreshed": "All MCP servers have been refreshed.", "project_config_deleted": "Project MCP configuration file deleted. All project MCP servers have been disconnected." + }, + "marketplace": { + "installed": "Added MCP server: {{serverName}}", + "alreadyInstalled": "Server \"{{serverName}}\" already exists in your MCP settings." } } diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index ea38ee02d6d..b72ab4f472d 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -1708,6 +1708,56 @@ export class McpHub { } } + /** + * Add a new MCP server to the global settings file. + * Used by the MCP Marketplace to install pre-configured servers. + */ + public async addServer(serverName: string, serverConfig: Record): Promise { + try { + const configPath = await this.getMcpSettingsFilePath() + + // Ensure the settings file exists and is accessible + try { + await fs.access(configPath) + } catch { + throw new Error("Settings file not accessible") + } + + const content = await fs.readFile(configPath, "utf-8") + const config = JSON.parse(content) + + if (!config || typeof config !== "object") { + throw new Error("Invalid config structure") + } + + if (!config.mcpServers || typeof config.mcpServers !== "object") { + config.mcpServers = {} + } + + // Check if server already exists + if (config.mcpServers[serverName]) { + vscode.window.showWarningMessage(t("mcp:marketplace.alreadyInstalled", { serverName })) + return + } + + config.mcpServers[serverName] = serverConfig + + const updatedConfig = { + mcpServers: config.mcpServers, + } + + await safeWriteJson(configPath, updatedConfig, { prettyPrint: true }) + + // Trigger server connections update + await this.updateServerConnections(config.mcpServers, "global") + + vscode.window.showInformationMessage(t("mcp:marketplace.installed", { serverName })) + } catch (error) { + this.showErrorMessage(`Failed to add MCP server ${serverName}`, error) + throw error + } + } + async readResource(serverName: string, uri: string, source?: "global" | "project"): Promise { const connection = this.findConnection(serverName, source) if (!connection || connection.type !== "connected") { diff --git a/src/shared/__tests__/mcpMarketplaceCatalog.spec.ts b/src/shared/__tests__/mcpMarketplaceCatalog.spec.ts new file mode 100644 index 00000000000..29d82c8899a --- /dev/null +++ b/src/shared/__tests__/mcpMarketplaceCatalog.spec.ts @@ -0,0 +1,49 @@ +import { mcpMarketplaceCatalog, type McpMarketplaceItem } from "../mcpMarketplaceCatalog" + +describe("mcpMarketplaceCatalog", () => { + it("should export a non-empty array of marketplace items", () => { + expect(mcpMarketplaceCatalog).toBeInstanceOf(Array) + expect(mcpMarketplaceCatalog.length).toBeGreaterThan(0) + }) + + it("should have unique names for each item", () => { + const names = mcpMarketplaceCatalog.map((item) => item.name) + const uniqueNames = new Set(names) + expect(uniqueNames.size).toBe(names.length) + }) + + it("each item should have required fields", () => { + for (const item of mcpMarketplaceCatalog) { + expect(item.name).toBeTruthy() + expect(item.displayName).toBeTruthy() + expect(item.description).toBeTruthy() + expect(item.category).toBeTruthy() + expect(item.config).toBeDefined() + expect(item.config.command).toBeTruthy() + expect(item.config.args).toBeInstanceOf(Array) + } + }) + + it("items with requiresSetup should have setupEnvKeys", () => { + const setupItems = mcpMarketplaceCatalog.filter((item) => item.requiresSetup) + for (const item of setupItems) { + expect(item.setupEnvKeys).toBeDefined() + expect(item.setupEnvKeys!.length).toBeGreaterThan(0) + } + }) + + it("should include DuckDuckGo search server", () => { + const ddg = mcpMarketplaceCatalog.find((item) => item.name === "ddg-search") + expect(ddg).toBeDefined() + expect(ddg!.config.command).toBe("uvx") + expect(ddg!.config.args).toContain("duckduckgo-mcp-server") + expect(ddg!.requiresSetup).toBeFalsy() + }) + + it("should include SearXNG server with setup required", () => { + const searxng = mcpMarketplaceCatalog.find((item) => item.name === "searxng") + expect(searxng).toBeDefined() + expect(searxng!.requiresSetup).toBe(true) + expect(searxng!.setupEnvKeys).toContain("SEARXNG_URL") + }) +}) diff --git a/src/shared/mcpMarketplaceCatalog.ts b/src/shared/mcpMarketplaceCatalog.ts new file mode 100644 index 00000000000..3ef78d60798 --- /dev/null +++ b/src/shared/mcpMarketplaceCatalog.ts @@ -0,0 +1,76 @@ +/** + * Pre-configured MCP server templates for one-click installation from the MCP Marketplace. + * Each entry defines a server configuration that can be written to the user's MCP settings file. + */ + +export interface McpMarketplaceItem { + /** Unique key used as the server name in mcpServers config */ + name: string + /** Human-readable display name */ + displayName: string + /** Short description of what the server does */ + description: string + /** Category tag for grouping */ + category: "search" | "tools" | "data" + /** The MCP server configuration to write */ + config: { + command: string + args: string[] + env?: Record + alwaysAllow?: string[] + } + /** Whether the server requires user-provided env variables before installation */ + requiresSetup?: boolean + /** Keys in env that need user customization (e.g. instance URLs) */ + setupEnvKeys?: string[] + /** URL for more info / docs */ + url?: string +} + +export const mcpMarketplaceCatalog: McpMarketplaceItem[] = [ + { + name: "ddg-search", + displayName: "DuckDuckGo Search", + description: "Free web search via DuckDuckGo. No API key required.", + category: "search", + config: { + command: "uvx", + args: ["duckduckgo-mcp-server"], + env: { + DDG_SAFE_SEARCH: "OFF", + DDG_REGION: "wt-wt", + }, + alwaysAllow: ["search", "fetch_content"], + }, + url: "https://pypi.org/project/duckduckgo-mcp-server/", + }, + { + name: "searxng", + displayName: "SearXNG", + description: "Privacy-focused metasearch engine. Requires a self-hosted SearXNG instance URL.", + category: "search", + config: { + command: "npx", + args: ["-y", "mcp-searxng"], + env: { + SEARXNG_URL: "https://searxng.example.com", + }, + alwaysAllow: ["searxng_web_search", "web_url_read"], + }, + requiresSetup: true, + setupEnvKeys: ["SEARXNG_URL"], + url: "https://www.npmjs.com/package/mcp-searxng", + }, + { + name: "web-search", + displayName: "Web Search (DuckDuckGo)", + description: "Lightweight web search via DuckDuckGo. No API key required. Uses npx.", + category: "search", + config: { + command: "npx", + args: ["-y", "github:tiagohanna123/web-search-mcp"], + alwaysAllow: [], + }, + url: "https://github.com/tiagohanna123/web-search-mcp", + }, +] diff --git a/webview-ui/src/components/mcp/McpMarketplace.tsx b/webview-ui/src/components/mcp/McpMarketplace.tsx new file mode 100644 index 00000000000..d26f1b3df9a --- /dev/null +++ b/webview-ui/src/components/mcp/McpMarketplace.tsx @@ -0,0 +1,160 @@ +import React, { useState } from "react" +import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" + +import { vscode } from "@src/utils/vscode" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Button } from "@src/components/ui" + +import { mcpMarketplaceCatalog, type McpMarketplaceItem } from "../../../../src/shared/mcpMarketplaceCatalog" + +const McpMarketplace = () => { + const { mcpServers: servers } = useExtensionState() + const { t } = useAppTranslation() + + const installedServerNames = new Set(servers.map((s) => s.name)) + + return ( +
+
+ {t("mcp:marketplace.title")} +
+
+ {t("mcp:marketplace.description")} +
+
+ {mcpMarketplaceCatalog.map((item) => ( + + ))} +
+
+ ) +} + +const MarketplaceRow = ({ item, isInstalled }: { item: McpMarketplaceItem; isInstalled: boolean }) => { + const { t } = useAppTranslation() + const [envValues, setEnvValues] = useState>(() => { + const initial: Record = {} + if (item.setupEnvKeys) { + for (const key of item.setupEnvKeys) { + initial[key] = item.config.env?.[key] ?? "" + } + } + return initial + }) + + const handleInstall = () => { + const config = { ...item.config } + if (item.requiresSetup && item.setupEnvKeys) { + config.env = { ...config.env } + for (const key of item.setupEnvKeys) { + if (envValues[key]) { + config.env[key] = envValues[key] + } + } + } + vscode.postMessage({ + type: "installMcpServer", + serverName: item.name, + config, + }) + } + + return ( +
+
+
+
+ + {item.displayName} + {item.requiresSetup && ( + + {t("mcp:marketplace.requiresSetup")} + + )} +
+
{item.description}
+
+
+ {item.url && ( + + {t("mcp:marketplace.learnMore")} + + )} + +
+
+ {item.requiresSetup && item.setupEnvKeys && !isInstalled && ( +
+ {item.setupEnvKeys.map((key) => ( +
+ + + setEnvValues((prev) => ({ + ...prev, + [key]: e.target.value, + })) + } + placeholder={item.config.env?.[key] ?? ""} + className="flex-1 rounded px-1.5 py-0.5 text-xs" + style={{ + background: "var(--vscode-input-background)", + color: "var(--vscode-input-foreground)", + border: "1px solid var(--vscode-input-border, transparent)", + }} + /> +
+ ))} +
+ )} +
+ ) +} + +export default McpMarketplace diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index c18e02989f7..77aadeb4358 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -26,6 +26,7 @@ import McpToolRow from "./McpToolRow" import McpResourceRow from "./McpResourceRow" import McpEnabledToggle from "./McpEnabledToggle" import { McpErrorRow } from "./McpErrorRow" +import McpMarketplace from "./McpMarketplace" const McpView = () => { const { mcpServers: servers, alwaysAllowMcp, mcpEnabled } = useExtensionState() @@ -133,6 +134,7 @@ const McpView = () => { {t("mcp:refreshMCP")} +
({ + useAppTranslation: () => ({ + t: (key: string, params?: Record) => { + const translations: Record = { + "mcp:marketplace.title": "Quick Add Servers", + "mcp:marketplace.description": + "Install popular free MCP servers with one click. No API keys required (unless noted).", + "mcp:marketplace.install": "Add", + "mcp:marketplace.requiresSetup": "Requires setup", + "mcp:marketplace.learnMore": "Learn more", + } + if (key === "mcp:marketplace.requiresSetupHint" && params) { + return `This server requires you to configure ${params.keys} in the MCP settings file after installation.` + } + return translations[key] || key + }, + }), +})) + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + mcpServers: [], + }), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeLink: function MockVSCodeLink({ children, href }: { children?: React.ReactNode; href?: string }) { + return {children} + }, +})) + +describe("McpMarketplace", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the marketplace title and description", () => { + render() + + expect(screen.getByText("Quick Add Servers")).toBeInTheDocument() + expect( + screen.getByText("Install popular free MCP servers with one click. No API keys required (unless noted)."), + ).toBeInTheDocument() + }) + + it("renders all catalog items", () => { + render() + + expect(screen.getByText("DuckDuckGo Search")).toBeInTheDocument() + expect(screen.getByText("SearXNG")).toBeInTheDocument() + expect(screen.getByText("Web Search (DuckDuckGo)")).toBeInTheDocument() + }) + + it("shows 'Requires setup' badge for servers that need configuration", () => { + render() + + // SearXNG requires setup + expect(screen.getByText("Requires setup")).toBeInTheDocument() + }) + + it("shows input fields for servers that require setup env keys", () => { + render() + + // SearXNG requires SEARXNG_URL + expect(screen.getByDisplayValue("https://searxng.example.com")).toBeInTheDocument() + }) + + it("sends installMcpServer message when Add button is clicked", () => { + render() + + // Click the first "Add" button (DuckDuckGo Search) + const addButtons = screen.getAllByText("Add") + fireEvent.click(addButtons[0]) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "installMcpServer", + serverName: "ddg-search", + config: { + command: "uvx", + args: ["duckduckgo-mcp-server"], + env: { + DDG_SAFE_SEARCH: "OFF", + DDG_REGION: "wt-wt", + }, + alwaysAllow: ["search", "fetch_content"], + }, + }) + }) + + it("sends installMcpServer with custom env values for servers requiring setup", () => { + render() + + // Change the SEARXNG_URL input value + const urlInput = screen.getByDisplayValue("https://searxng.example.com") + fireEvent.change(urlInput, { target: { value: "https://my-searxng.local" } }) + + // Click the Add button for SearXNG (second Add button) + const addButtons = screen.getAllByText("Add") + fireEvent.click(addButtons[1]) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "installMcpServer", + serverName: "searxng", + config: { + command: "npx", + args: ["-y", "mcp-searxng"], + env: { + SEARXNG_URL: "https://my-searxng.local", + }, + alwaysAllow: ["searxng_web_search", "web_url_read"], + }, + }) + }) + + it("renders learn more links for servers with URLs", () => { + render() + + const learnMoreLinks = screen.getAllByText("Learn more") + // All 3 catalog items have URLs + expect(learnMoreLinks.length).toBe(3) + }) +}) diff --git a/webview-ui/src/i18n/locales/en/mcp.json b/webview-ui/src/i18n/locales/en/mcp.json index cb774c327fe..13e0fa7cf48 100644 --- a/webview-ui/src/i18n/locales/en/mcp.json +++ b/webview-ui/src/i18n/locales/en/mcp.json @@ -60,5 +60,20 @@ "running": "Running", "completed": "Completed", "error": "Error" + }, + "marketplace": { + "title": "Quick Add Servers", + "description": "Install popular free MCP servers with one click. No API keys required (unless noted).", + "install": "Add", + "installed": "Server \"{{serverName}}\" has been added to your MCP settings.", + "alreadyInstalled": "Server \"{{serverName}}\" already exists in your MCP settings.", + "requiresSetup": "Requires setup", + "requiresSetupHint": "This server requires you to configure {{keys}} in the MCP settings file after installation.", + "category": { + "search": "Search", + "tools": "Tools", + "data": "Data" + }, + "learnMore": "Learn more" } }