mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
dcc6db00c7
commit
9619603b1c
9 changed files with 855 additions and 1 deletions
|
|
@ -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/),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
42
webview-ui/src/components/ui/alert.tsx
Normal file
42
webview-ui/src/components/ui/alert.tsx
Normal 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 }
|
||||
20
webview-ui/src/components/ui/label.tsx
Normal file
20
webview-ui/src/components/ui/label.tsx
Normal 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 }
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue