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
This commit is contained in:
Roo Code 2025-09-17 07:50:14 +00:00
parent dcc6db00c7
commit 9619603b1c
9 changed files with 855 additions and 1 deletions

View file

@ -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/),
)
})
})

View file

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

View file

@ -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<string, any> // Add config to the payload
visibility?: ShareVisibility // For share visibility
customMcpConfig?: { command: string; args?: string[]; env?: Record<string, string> } // For custom MCP server
hasContent?: boolean // For checkRulesDirectoryResult
checkOnly?: boolean // For deleteCustomMode check
upsellId?: string // For dismissUpsell

View file

@ -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 && (
<CustomMcpDialog
onClose={() => setShowCustomMcpDialog(false)}
onSuccess={() => {
setShowCustomMcpDialog(false)
// Optionally refresh the marketplace data
manager.transition({ type: "REFRESH" })
}}
/>
)}
<div className="mb-4">
{/* Add Custom MCP Button for MCP tab */}
{filterByType === "mcp" && (
<div className="mb-3 flex justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => setShowCustomMcpDialog(true)}
className="flex items-center gap-2">
<Plus className="h-4 w-4" />
{t("marketplace:customMcp.button")}
</Button>
</div>
)}
<div className="relative">
<Input
type="text"

View file

@ -0,0 +1,188 @@
import React, { useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { vscode } from "@/utils/vscode"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { InfoIcon } from "lucide-react"
interface CustomMcpDialogProps {
onClose: () => 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<string | null>(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<string, string> = {}
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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t("marketplace:customMcp.title")}</DialogTitle>
<DialogDescription>{t("marketplace:customMcp.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{showSerenaExample && (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>
<div className="space-y-2">
<p>Looking to add Serena MCP server? Here&apos;s an example configuration:</p>
<Button variant="link" className="p-0 h-auto" onClick={loadSerenaExample}>
Load Serena Example
</Button>
</div>
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="serverName">{t("marketplace:customMcp.serverName")}</Label>
<Input
id="serverName"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
placeholder={t("marketplace:customMcp.serverNamePlaceholder")}
disabled={isSubmitting}
/>
{serverName === "" && (
<Button
variant="link"
className="text-xs p-0 h-auto"
onClick={() => setShowSerenaExample(true)}>
Looking for Serena MCP?
</Button>
)}
</div>
<div className="space-y-2">
<Label htmlFor="command">{t("marketplace:customMcp.command")}</Label>
<Input
id="command"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder={t("marketplace:customMcp.commandPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="args">{t("marketplace:customMcp.args")}</Label>
<Input
id="args"
value={args}
onChange={(e) => setArgs(e.target.value)}
placeholder={t("marketplace:customMcp.argsPlaceholder")}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">Separate multiple arguments with commas</p>
</div>
<div className="space-y-2">
<Label htmlFor="env">{t("marketplace:customMcp.env")}</Label>
<Textarea
id="env"
value={env}
onChange={(e) => setEnv(e.target.value)}
placeholder={t("marketplace:customMcp.envPlaceholder")}
disabled={isSubmitting}
rows={3}
/>
<p className="text-xs text-muted-foreground">Format: KEY=value, one per line</p>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{t("marketplace:customMcp.cancel")}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "Adding..." : t("marketplace:customMcp.add")}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,232 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { CustomMcpDialog } from "../CustomMcpDialog"
import { vscode } from "@/utils/vscode"
// Mock vscode
vi.mock("@/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock translation hook
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"marketplace:customMcp.title": "Add Custom MCP Server",
"marketplace:customMcp.description": "Configure a custom MCP server",
"marketplace:customMcp.serverName": "Server Name",
"marketplace:customMcp.serverNamePlaceholder": "e.g., my-mcp-server",
"marketplace:customMcp.command": "Command",
"marketplace:customMcp.commandPlaceholder": "e.g., npx",
"marketplace:customMcp.args": "Arguments (comma-separated)",
"marketplace:customMcp.argsPlaceholder": "e.g., -y, @serena/mcp-server",
"marketplace:customMcp.env": "Environment Variables (optional, KEY=value format, one per line)",
"marketplace:customMcp.envPlaceholder": "KEY=value",
"marketplace:customMcp.cancel": "Cancel",
"marketplace:customMcp.add": "Add Server",
}
return translations[key] || key
},
}),
}))
describe("CustomMcpDialog", () => {
const mockOnClose = vi.fn()
const mockOnSuccess = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it("should render the dialog", () => {
render(<CustomMcpDialog onClose={mockOnClose} onSuccess={mockOnSuccess} />)
expect(screen.getByText("Add Custom MCP Server")).toBeInTheDocument()
expect(screen.getByLabelText("Server Name")).toBeInTheDocument()
expect(screen.getByLabelText("Command")).toBeInTheDocument()
expect(screen.getByLabelText("Arguments (comma-separated)")).toBeInTheDocument()
expect(
screen.getByLabelText("Environment Variables (optional, KEY=value format, one per line)"),
).toBeInTheDocument()
})
it("should load Serena example when clicking the example button", () => {
render(<CustomMcpDialog onClose={mockOnClose} onSuccess={mockOnSuccess} />)
// First click "Looking for Serena MCP?" to show the example
const lookingForSerena = screen.getByText("Looking for Serena MCP?")
fireEvent.click(lookingForSerena)
// Then click "Load Serena Example"
const exampleButton = screen.getByText("Load Serena Example")
fireEvent.click(exampleButton)
const serverNameInput = screen.getByLabelText("Server Name") as HTMLInputElement
const commandInput = screen.getByLabelText("Command") as HTMLInputElement
const argsInput = screen.getByLabelText("Arguments (comma-separated)") as HTMLInputElement
expect(serverNameInput.value).toBe("serena-mcp")
expect(commandInput.value).toBe("npx")
expect(argsInput.value).toBe("-y, @serena/mcp-server")
})
it("should validate required fields", async () => {
render(<CustomMcpDialog onClose={mockOnClose} onSuccess={mockOnSuccess} />)
const addButton = screen.getByText("Add Server")
fireEvent.click(addButton)
// Should show validation error for server name
await waitFor(() => {
expect(screen.getByText("Server name is required")).toBeInTheDocument()
})
// Should not post message when validation fails
expect(vscode.postMessage).not.toHaveBeenCalled()
expect(mockOnSuccess).not.toHaveBeenCalled()
})
it("should send correct message when adding a server", async () => {
render(<CustomMcpDialog onClose={mockOnClose} onSuccess={mockOnSuccess} />)
// Fill in the form
const serverNameInput = screen.getByLabelText("Server Name") as HTMLInputElement
const commandInput = screen.getByLabelText("Command") as HTMLInputElement
const argsInput = screen.getByLabelText("Arguments (comma-separated)") as HTMLInputElement
const envInput = screen.getByLabelText(
"Environment Variables (optional, KEY=value format, one per line)",
) as HTMLTextAreaElement
fireEvent.change(serverNameInput, { target: { value: "test-server" } })
fireEvent.change(commandInput, { target: { value: "node" } })
fireEvent.change(argsInput, { target: { value: "server.js, --port, 3000" } })
fireEvent.change(envInput, { target: { value: "API_KEY=test123\nDEBUG=true" } })
// Click add button
const addButton = screen.getByText("Add Server")
fireEvent.click(addButton)
// Should send the correct message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "addCustomMcpServer",
serverName: "test-server",
customMcpConfig: {
command: "node",
args: ["server.js", "--port", "3000"],
env: {
API_KEY: "test123",
DEBUG: "true",
},
},
})
})
// Should call onSuccess
expect(mockOnSuccess).toHaveBeenCalled()
})
it("should handle empty arguments correctly", async () => {
render(<CustomMcpDialog onClose={mockOnClose} onSuccess={mockOnSuccess} />)
// Fill in only required fields
const serverNameInput = screen.getByLabelText("Server Name") as HTMLInputElement
const commandInput = screen.getByLabelText("Command") as HTMLInputElement
fireEvent.change(serverNameInput, { target: { value: "simple-server" } })
fireEvent.change(commandInput, { target: { value: "python" } })
// Click add button
const addButton = screen.getByText("Add Server")
fireEvent.click(addButton)
// Should send message with empty args (no env field when empty)
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "addCustomMcpServer",
serverName: "simple-server",
customMcpConfig: {
command: "python",
args: [],
},
})
})
})
it("should close dialog when clicking cancel", () => {
render(<CustomMcpDialog onClose={mockOnClose} onSuccess={mockOnSuccess} />)
const cancelButton = screen.getByText("Cancel")
fireEvent.click(cancelButton)
expect(mockOnClose).toHaveBeenCalled()
expect(vscode.postMessage).not.toHaveBeenCalled()
expect(mockOnSuccess).not.toHaveBeenCalled()
})
it("should parse environment variables correctly", async () => {
render(<CustomMcpDialog onClose={mockOnClose} onSuccess={mockOnSuccess} />)
const serverNameInput = screen.getByLabelText("Server Name") as HTMLInputElement
const commandInput = screen.getByLabelText("Command") as HTMLInputElement
const envInput = screen.getByLabelText(
"Environment Variables (optional, KEY=value format, one per line)",
) as HTMLTextAreaElement
fireEvent.change(serverNameInput, { target: { value: "env-test" } })
fireEvent.change(commandInput, { target: { value: "test" } })
// Test various env formats including spaces and special characters
fireEvent.change(envInput, {
target: { value: "KEY1=value1\nKEY2 = value with spaces\nKEY3=\nINVALID_LINE\n KEY4=trimmed " },
})
const addButton = screen.getByText("Add Server")
fireEvent.click(addButton)
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "addCustomMcpServer",
serverName: "env-test",
customMcpConfig: {
command: "test",
args: [],
env: {
KEY1: "value1",
KEY2: "value with spaces",
KEY3: "",
KEY4: "trimmed",
},
},
})
})
})
it("should trim whitespace from inputs", async () => {
render(<CustomMcpDialog onClose={mockOnClose} onSuccess={mockOnSuccess} />)
const serverNameInput = screen.getByLabelText("Server Name") as HTMLInputElement
const commandInput = screen.getByLabelText("Command") as HTMLInputElement
const argsInput = screen.getByLabelText("Arguments (comma-separated)") as HTMLInputElement
fireEvent.change(serverNameInput, { target: { value: " trimmed-server " } })
fireEvent.change(commandInput, { target: { value: " node " } })
fireEvent.change(argsInput, { target: { value: " arg1 , arg2 " } })
const addButton = screen.getByText("Add Server")
fireEvent.click(addButton)
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "addCustomMcpServer",
serverName: "trimmed-server",
customMcpConfig: {
command: "node",
args: ["arg1", "arg2"],
},
})
})
})
})

View file

@ -0,0 +1,42 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
),
)
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
),
)
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View file

@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export type LabelProps = React.LabelHTMLAttributes<HTMLLabelElement>
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => {
return (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
)
})
Label.displayName = "Label"
export { Label }

View file

@ -45,6 +45,23 @@
"organizationMcps": "{{organization}} MCPs",
"marketplace": "Marketplace"
},
"customMcp": {
"button": "Add Custom MCP",
"title": "Add Custom MCP Server",
"description": "Configure a custom MCP server that's not available in the marketplace",
"serverName": "Server Name",
"serverNamePlaceholder": "e.g., serena-mcp",
"command": "Command",
"commandPlaceholder": "e.g., npx -y @serena/mcp-server",
"args": "Arguments (optional)",
"argsPlaceholder": "Comma-separated arguments",
"env": "Environment Variables (optional)",
"envPlaceholder": "KEY=value, one per line",
"cancel": "Cancel",
"add": "Add Server",
"success": "Custom MCP server added successfully",
"error": "Failed to add custom MCP server: {{error}}"
},
"type-group": {
"modes": "Modes",
"mcps": "MCP Servers"