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
This commit is contained in:
Roo Code 2026-05-14 04:21:51 +00:00
parent e921f9d21e
commit e6db23b87c
10 changed files with 510 additions and 0 deletions

View file

@ -451,6 +451,7 @@ export interface WebviewMessage {
| "checkpointDiff"
| "checkpointRestore"
| "deleteMcpServer"
| "installMcpServer"
| "codebaseIndexEnabled"
| "searchFiles"
| "toggleApiConfigPin"

View file

@ -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")

View file

@ -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."
}
}

View file

@ -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<string, unknown>): Promise<void> {
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<McpResourceResponse> {
const connection = this.findConnection(serverName, source)
if (!connection || connection.type !== "connected") {

View file

@ -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")
})
})

View file

@ -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<string, string>
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",
},
]

View file

@ -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 (
<div style={{ marginTop: "15px" }}>
<div
style={{
fontWeight: 500,
fontSize: "13px",
color: "var(--vscode-foreground)",
marginBottom: "6px",
}}>
{t("mcp:marketplace.title")}
</div>
<div
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
marginBottom: "10px",
}}>
{t("mcp:marketplace.description")}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{mcpMarketplaceCatalog.map((item) => (
<MarketplaceRow key={item.name} item={item} isInstalled={installedServerNames.has(item.name)} />
))}
</div>
</div>
)
}
const MarketplaceRow = ({ item, isInstalled }: { item: McpMarketplaceItem; isInstalled: boolean }) => {
const { t } = useAppTranslation()
const [envValues, setEnvValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {}
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 (
<div
className="rounded bg-vscode-textCodeBlock-background p-2"
style={{
opacity: isInstalled ? 0.6 : 1,
}}>
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="codicon codicon-search text-xs" />
<span className="font-medium text-[13px] text-vscode-foreground">{item.displayName}</span>
{item.requiresSetup && (
<span
className="text-[10px] px-1 py-0.5 rounded"
style={{
background: "var(--vscode-editorWarning-foreground)",
color: "var(--vscode-editor-background)",
}}
title={t("mcp:marketplace.requiresSetupHint", {
keys: item.setupEnvKeys?.join(", ") ?? "",
})}>
{t("mcp:marketplace.requiresSetup")}
</span>
)}
</div>
<div className="text-xs text-vscode-descriptionForeground mt-0.5">{item.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{item.url && (
<VSCodeLink href={item.url} style={{ fontSize: "11px" }}>
{t("mcp:marketplace.learnMore")}
</VSCodeLink>
)}
<Button
variant="secondary"
disabled={isInstalled}
onClick={handleInstall}
style={{ minWidth: "60px", fontSize: "12px" }}>
{isInstalled ? (
<>
<span className="codicon codicon-check mr-1" />
Added
</>
) : (
<>
<span className="codicon codicon-add mr-1" />
{t("mcp:marketplace.install")}
</>
)}
</Button>
</div>
</div>
{item.requiresSetup && item.setupEnvKeys && !isInstalled && (
<div className="mt-2 flex flex-col gap-1.5">
{item.setupEnvKeys.map((key) => (
<div key={key} className="flex items-center gap-2">
<label
className="text-[11px] text-vscode-descriptionForeground shrink-0"
style={{ minWidth: "90px" }}>
{key}:
</label>
<input
type="text"
value={envValues[key] ?? ""}
onChange={(e) =>
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)",
}}
/>
</div>
))}
</div>
)}
</div>
)
}
export default McpMarketplace

View file

@ -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")}
</Button>
</div>
<McpMarketplace />
<div
style={{
marginTop: "15px",

View file

@ -0,0 +1,135 @@
import React from "react"
import { render, fireEvent, screen } from "@/utils/test-utils"
import { vscode } from "@src/utils/vscode"
import McpMarketplace from "../McpMarketplace"
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string, params?: Record<string, string>) => {
const translations: Record<string, string> = {
"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 <a href={href}>{children}</a>
},
}))
describe("McpMarketplace", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("renders the marketplace title and description", () => {
render(<McpMarketplace />)
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(<McpMarketplace />)
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(<McpMarketplace />)
// SearXNG requires setup
expect(screen.getByText("Requires setup")).toBeInTheDocument()
})
it("shows input fields for servers that require setup env keys", () => {
render(<McpMarketplace />)
// SearXNG requires SEARXNG_URL
expect(screen.getByDisplayValue("https://searxng.example.com")).toBeInTheDocument()
})
it("sends installMcpServer message when Add button is clicked", () => {
render(<McpMarketplace />)
// 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(<McpMarketplace />)
// 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(<McpMarketplace />)
const learnMoreLinks = screen.getAllByText("Learn more")
// All 3 catalog items have URLs
expect(learnMoreLinks.length).toBe(3)
})
})

View file

@ -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"
}
}