Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 251 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.customMcp.spec.ts
Original file line number Diff line number Diff line change
@@ -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/),
)
})
})
75 changes: 75 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intentional? The success message shows even if refreshAllConnections() fails. Consider wrapping the refresh in a try-catch to handle failures gracefully and provide appropriate user feedback.


// 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
}
}
}
2 changes: 2 additions & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export interface WebviewMessage {
| "editQueuedMessage"
| "dismissUpsell"
| "getDismissedUpsells"
| "addCustomMcpServer"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading