diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b98934400d..403c6794df 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1114,6 +1114,28 @@ export class ClineProvider implements vscode.WebviewViewProvider { } break } + case "openProjectMcpSettings": { + if (!vscode.workspace.workspaceFolders?.length) { + vscode.window.showErrorMessage("Please open a project folder first") + return + } + + const workspaceFolder = vscode.workspace.workspaceFolders[0] + const rooDir = path.join(workspaceFolder.uri.fsPath, ".roo") + const mcpPath = path.join(rooDir, "mcp.json") + + try { + await fs.mkdir(rooDir, { recursive: true }) + const exists = await fileExistsAtPath(mcpPath) + if (!exists) { + await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: {} }, null, 2)) + } + await openFile(mcpPath) + } catch (error) { + vscode.window.showErrorMessage(`Failed to create or open .roo/mcp.json: ${error}`) + } + break + } case "openCustomModesSettings": { const customModesFilePath = await this.customModesManager.getCustomModesFilePath() if (customModesFilePath) { diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index abe7a8475a..6d75c01c69 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -1950,6 +1950,130 @@ describe("ClineProvider", () => { }) }) +describe("Project MCP Settings", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockWebviewView: vscode.WebviewView + let mockPostMessage: jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + + mockContext = { + extensionPath: "/test/path", + extensionUri: {} as vscode.Uri, + globalState: { + get: jest.fn(), + update: jest.fn(), + keys: jest.fn().mockReturnValue([]), + }, + secrets: { + get: jest.fn(), + store: jest.fn(), + delete: jest.fn(), + }, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: jest.fn(), + clear: jest.fn(), + dispose: jest.fn(), + } as unknown as vscode.OutputChannel + + mockPostMessage = jest.fn() + mockWebviewView = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: jest.fn(), + asWebviewUri: jest.fn(), + }, + visible: true, + onDidDispose: jest.fn(), + onDidChangeVisibility: jest.fn(), + } as unknown as vscode.WebviewView + + provider = new ClineProvider(mockContext, mockOutputChannel) + }) + + test("handles openProjectMcpSettings message", async () => { + await provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Mock workspace folders + ;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + // Mock fs functions + const fs = require("fs/promises") + fs.mkdir.mockResolvedValue(undefined) + fs.writeFile.mockResolvedValue(undefined) + + // Trigger openProjectMcpSettings + await messageHandler({ + type: "openProjectMcpSettings", + }) + + // Verify directory was created + expect(fs.mkdir).toHaveBeenCalledWith( + expect.stringContaining(".roo"), + expect.objectContaining({ recursive: true }), + ) + + // Verify file was created with default content + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining("mcp.json"), + JSON.stringify({ mcpServers: {} }, null, 2), + ) + }) + + test("handles openProjectMcpSettings when workspace is not open", async () => { + await provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Mock no workspace folders + ;(vscode.workspace as any).workspaceFolders = [] + + // Trigger openProjectMcpSettings + await messageHandler({ + type: "openProjectMcpSettings", + }) + + // Verify error message was shown + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Please open a project folder first") + }) + + test("handles openProjectMcpSettings file creation error", async () => { + await provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Mock workspace folders + ;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + // Mock fs functions to fail + const fs = require("fs/promises") + fs.mkdir.mockRejectedValue(new Error("Failed to create directory")) + + // Trigger openProjectMcpSettings + await messageHandler({ + type: "openProjectMcpSettings", + }) + + // Verify error message was shown + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("Failed to create or open .roo/mcp.json"), + ) + }) +}) + describe("ContextProxy integration", () => { let provider: ClineProvider let mockContext: vscode.ExtensionContext diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 7dc1fb8531..bd0ccd5e1f 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -73,6 +73,7 @@ export class McpHub { private providerRef: WeakRef private disposables: vscode.Disposable[] = [] private settingsWatcher?: vscode.FileSystemWatcher + private projectMcpWatcher?: vscode.FileSystemWatcher private fileWatchers: Map = new Map() private isDisposed: boolean = false connections: McpConnection[] = [] @@ -81,9 +82,55 @@ export class McpHub { constructor(provider: ClineProvider) { this.providerRef = new WeakRef(provider) this.watchMcpSettingsFile() + this.watchProjectMcpFile() + this.setupWorkspaceFoldersWatcher() this.initializeMcpServers() } + private setupWorkspaceFoldersWatcher(): void { + this.disposables.push( + vscode.workspace.onDidChangeWorkspaceFolders(async () => { + await this.updateProjectMcpServers() + this.watchProjectMcpFile() + }), + ) + } + + private watchProjectMcpFile(): void { + this.projectMcpWatcher?.dispose() + + this.projectMcpWatcher = vscode.workspace.createFileSystemWatcher("**/.roo/mcp.json", false, false, false) + + this.disposables.push( + this.projectMcpWatcher.onDidChange(async () => { + await this.updateProjectMcpServers() + }), + this.projectMcpWatcher.onDidCreate(async () => { + await this.updateProjectMcpServers() + }), + this.projectMcpWatcher.onDidDelete(async () => { + await this.cleanupProjectMcpServers() + }), + ) + + this.disposables.push(this.projectMcpWatcher) + } + + private async updateProjectMcpServers(): Promise { + await this.cleanupProjectMcpServers() + await this.initializeProjectMcpServers() + } + + private async cleanupProjectMcpServers(): Promise { + const projectServers = this.connections.filter((conn) => conn.server.source === "project") + + for (const conn of projectServers) { + await this.deleteConnection(conn.server.name) + } + + await this.notifyWebviewOfServerChanges() + } + getServers(): McpServer[] { // Only return enabled servers return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server) @@ -158,16 +205,68 @@ export class McpHub { private async initializeMcpServers(): Promise { try { + // 1. Initialize global MCP servers const settingsPath = await this.getMcpSettingsFilePath() const content = await fs.readFile(settingsPath, "utf-8") const config = JSON.parse(content) - await this.updateServerConnections(config.mcpServers || {}) + await this.updateServerConnections(config.mcpServers || {}, "global") + + // 2. Initialize project-level MCP servers + await this.initializeProjectMcpServers() } catch (error) { console.error("Failed to initialize MCP servers:", error) } } - private async connectToServer(name: string, config: z.infer): Promise { + // Get project-level MCP configuration path + private async getProjectMcpPath(): Promise { + if (!vscode.workspace.workspaceFolders?.length) { + return null + } + + const workspaceFolder = vscode.workspace.workspaceFolders[0] + const projectMcpDir = path.join(workspaceFolder.uri.fsPath, ".roo") + const projectMcpPath = path.join(projectMcpDir, "mcp.json") + + try { + await fs.access(projectMcpPath) + return projectMcpPath + } catch { + return null + } + } + + // Initialize project-level MCP servers + private async initializeProjectMcpServers(): Promise { + const projectMcpPath = await this.getProjectMcpPath() + if (!projectMcpPath) { + return + } + + try { + const content = await fs.readFile(projectMcpPath, "utf-8") + const config = JSON.parse(content) + + // Validate configuration structure + const result = McpSettingsSchema.safeParse(config) + if (!result.success) { + vscode.window.showErrorMessage("项目 MCP 配置格式无效") + return + } + + // Update server connections + await this.updateServerConnections(result.data.mcpServers || {}, "project") + } catch (error) { + console.error("Failed to initialize project MCP servers:", error) + vscode.window.showErrorMessage(`初始化项目 MCP 服务器失败: ${error}`) + } + } + + private async connectToServer( + name: string, + config: z.infer, + source: "global" | "project" = "global", + ): Promise { // Remove existing connection if it exists await this.deleteConnection(name) @@ -272,6 +371,8 @@ export class McpHub { config: JSON.stringify(config), status: "connecting", disabled: config.disabled, + source, + projectPath: source === "project" ? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath : undefined, }, client, transport, @@ -366,10 +467,17 @@ export class McpHub { } } - async updateServerConnections(newServers: Record): Promise { + async updateServerConnections( + newServers: Record, + source: "global" | "project" = "global", + ): Promise { this.isConnecting = true this.removeAllFileWatchers() - const currentNames = new Set(this.connections.map((conn) => conn.server.name)) + // Filter connections by source + const currentConnections = this.connections.filter( + (conn) => conn.server.source === source || (!conn.server.source && source === "global"), + ) + const currentNames = new Set(currentConnections.map((conn) => conn.server.name)) const newNames = new Set(Object.keys(newServers)) // Delete removed servers @@ -388,7 +496,7 @@ export class McpHub { // New server try { this.setupFileWatcher(name, config) - await this.connectToServer(name, config) + await this.connectToServer(name, config, source) } catch (error) { console.error(`Failed to connect to new MCP server ${name}:`, error) } @@ -397,8 +505,8 @@ export class McpHub { try { this.setupFileWatcher(name, config) await this.deleteConnection(name) - await this.connectToServer(name, config) - console.log(`Reconnected MCP server with updated config: ${name}`) + await this.connectToServer(name, config, source) + console.log(`Reconnected ${source} MCP server with updated config: ${name}`) } catch (error) { console.error(`Failed to reconnect MCP server ${name}:`, error) } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index e9a64d891b..f00a9edd11 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -59,6 +59,7 @@ export interface WebviewMessage { | "screenshotQuality" | "remoteBrowserHost" | "openMcpSettings" + | "openProjectMcpSettings" | "restartMcpServer" | "toggleToolAlwaysAllow" | "toggleMcpServer" diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts index 2bc38a12a8..7a490851bc 100644 --- a/src/shared/mcp.ts +++ b/src/shared/mcp.ts @@ -8,6 +8,8 @@ export type McpServer = { resourceTemplates?: McpResourceTemplate[] disabled?: boolean timeout?: number + source?: "global" | "project" + projectPath?: string } export type McpTool = { diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index ce37a4c09d..b615f96d08 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -94,16 +94,25 @@ const McpView = ({ onDone }: McpViewProps) => { )} - {/* Edit Settings Button */} -
+ {/* Edit Settings Buttons */} +
{ vscode.postMessage({ type: "openMcpSettings" }) }}> - Edit MCP Settings + Edit Global MCP + + { + vscode.postMessage({ type: "openProjectMcpSettings" }) + }}> + + Edit Project MCP
@@ -193,7 +202,22 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowM style={{ marginRight: "8px" }} /> )} - {server.name} + + {server.name} + {server.source && ( + + {server.source} + + )} +
e.stopPropagation()}>