diff --git a/CHANGELOG.md b/CHANGELOG.md index db1e6a9..f3cf1dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Roo Cline Changelog +## [2.2.5] + +- Allow MCP servers to be enabled/disabled + ## [2.2.4] - Tweak the prompt to encourage diff edits when they're enabled diff --git a/README.md b/README.md index be5f192..e7e0b78 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A fork of Cline, an autonomous coding agent, optimized for speed and flexibility - Support for newer Gemini models (gemini-exp-1206 and gemini-2.0-flash-exp) - Support for dragging and dropping images into chats - Support for auto-approving MCP tools +- Support for enabling/disabling MCP servers ## Disclaimer diff --git a/package-lock.json b/package-lock.json index 40baa77..4731e27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roo-cline", - "version": "2.2.4", + "version": "2.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roo-cline", - "version": "2.2.4", + "version": "2.2.5", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.26.0", diff --git a/package.json b/package.json index d5798be..19de801 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Roo Cline", "description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.", "publisher": "RooVeterinaryInc", - "version": "2.2.4", + "version": "2.2.5", "icon": "assets/icons/rocket.png", "galleryBanner": { "color": "#617A91", diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index de3c19e..b497368 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -633,7 +633,7 @@ npm run build 5. Install the MCP Server by adding the MCP server configuration to the settings file located at '${await mcpHub.getMcpSettingsFilePath()}'. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing \`mcpServers\` object. -IMPORTANT: Regardless of what else you see in the settings file, you must not set any defaults for the \`alwaysAllow\` array in the newly added MCP server. +IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false and alwaysAllow=[]. \`\`\`json { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5877f4f..e998332 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -567,6 +567,17 @@ export class ClineProvider implements vscode.WebviewViewProvider { } break } + case "toggleMcpServer": { + try { + await this.mcpHub?.toggleServerDisabled( + message.serverName!, + message.disabled! + ) + } catch (error) { + console.error(`Failed to toggle MCP server ${message.serverName}:`, error) + } + break + } // Add more switch case statements here as more webview message commands // are created within the webview context (i.e. inside media/main.js) case "playSound": diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 715410e..9004a78 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -39,7 +39,8 @@ const StdioConfigSchema = z.object({ command: z.string(), args: z.array(z.string()).optional(), env: z.record(z.string()).optional(), - alwaysAllow: AlwaysAllowSchema.optional() + alwaysAllow: AlwaysAllowSchema.optional(), + disabled: z.boolean().optional() }) const McpSettingsSchema = z.object({ @@ -61,7 +62,10 @@ export class McpHub { } getServers(): McpServer[] { - return this.connections.map((conn) => conn.server) + // Only return enabled servers + return this.connections + .filter((conn) => !conn.server.disabled) + .map((conn) => conn.server) } async getMcpServersPath(): Promise { @@ -117,9 +121,7 @@ export class McpHub { return } try { - vscode.window.showInformationMessage("Updating MCP servers...") await this.updateServerConnections(result.data.mcpServers || {}) - vscode.window.showInformationMessage("MCP servers updated") } catch (error) { console.error("Failed to process MCP settings change:", error) } @@ -202,11 +204,13 @@ export class McpHub { } // valid schema + const parsedConfig = StdioConfigSchema.parse(config) const connection: McpConnection = { server: { name, config: JSON.stringify(config), status: "connecting", + disabled: parsedConfig.disabled, }, client, transport, @@ -466,13 +470,89 @@ export class McpHub { }) } - // Using server + // Public methods for server management + + public async toggleServerDisabled(serverName: string, disabled: boolean): Promise { + let settingsPath: string + try { + settingsPath = await this.getMcpSettingsFilePath() + + // Ensure the settings file exists and is accessible + try { + await fs.access(settingsPath) + } catch (error) { + console.error('Settings file not accessible:', error) + throw new Error('Settings file not accessible') + } + const content = await fs.readFile(settingsPath, "utf-8") + const config = JSON.parse(content) + + // Validate the config structure + if (!config || typeof config !== 'object') { + throw new Error('Invalid config structure') + } + + if (!config.mcpServers || typeof config.mcpServers !== 'object') { + config.mcpServers = {} + } + + if (config.mcpServers[serverName]) { + // Create a new server config object to ensure clean structure + const serverConfig = { + ...config.mcpServers[serverName], + disabled + } + + // Ensure required fields exist + if (!serverConfig.alwaysAllow) { + serverConfig.alwaysAllow = [] + } + + config.mcpServers[serverName] = serverConfig + + // Write the entire config back + const updatedConfig = { + mcpServers: config.mcpServers + } + + await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2)) + + const connection = this.connections.find(conn => conn.server.name === serverName) + if (connection) { + try { + connection.server.disabled = disabled + + // Only refresh capabilities if connected + if (connection.server.status === "connected") { + connection.server.tools = await this.fetchToolsList(serverName) + connection.server.resources = await this.fetchResourcesList(serverName) + connection.server.resourceTemplates = await this.fetchResourceTemplatesList(serverName) + } + } catch (error) { + console.error(`Failed to refresh capabilities for ${serverName}:`, error) + } + } + + await this.notifyWebviewOfServerChanges() + } + } catch (error) { + console.error("Failed to update server disabled state:", error) + if (error instanceof Error) { + console.error("Error details:", error.message, error.stack) + } + vscode.window.showErrorMessage(`Failed to update server state: ${error instanceof Error ? error.message : String(error)}`) + throw error + } + } async readResource(serverName: string, uri: string): Promise { const connection = this.connections.find((conn) => conn.server.name === serverName) if (!connection) { throw new Error(`No connection found for server: ${serverName}`) } + if (connection.server.disabled) { + throw new Error(`Server "${serverName}" is disabled`) + } return await connection.client.request( { method: "resources/read", @@ -495,6 +575,9 @@ export class McpHub { `No connection found for server: ${serverName}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`, ) } + if (connection.server.disabled) { + throw new Error(`Server "${serverName}" is disabled and cannot be used`) + } return await connection.client.request( { diff --git a/src/services/mcp/__tests__/McpHub.test.ts b/src/services/mcp/__tests__/McpHub.test.ts index cf4899b..cd63e29 100644 --- a/src/services/mcp/__tests__/McpHub.test.ts +++ b/src/services/mcp/__tests__/McpHub.test.ts @@ -148,6 +148,103 @@ describe('McpHub', () => { }) }) + describe('server disabled state', () => { + it('should toggle server disabled state', async () => { + const mockConfig = { + mcpServers: { + 'test-server': { + command: 'node', + args: ['test.js'], + disabled: false + } + } + } + + // Mock reading initial config + ;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig)) + + await mcpHub.toggleServerDisabled('test-server', true) + + // Verify the config was updated correctly + const writeCall = (fs.writeFile as jest.Mock).mock.calls[0] + const writtenConfig = JSON.parse(writeCall[1]) + expect(writtenConfig.mcpServers['test-server'].disabled).toBe(true) + }) + + it('should filter out disabled servers from getServers', () => { + const mockConnections: McpConnection[] = [ + { + server: { + name: 'enabled-server', + config: '{}', + status: 'connected', + disabled: false + }, + client: {} as any, + transport: {} as any + }, + { + server: { + name: 'disabled-server', + config: '{}', + status: 'connected', + disabled: true + }, + client: {} as any, + transport: {} as any + } + ] + + mcpHub.connections = mockConnections + const servers = mcpHub.getServers() + + expect(servers.length).toBe(1) + expect(servers[0].name).toBe('enabled-server') + }) + + it('should prevent calling tools on disabled servers', async () => { + const mockConnection: McpConnection = { + server: { + name: 'disabled-server', + config: '{}', + status: 'connected', + disabled: true + }, + client: { + request: jest.fn().mockResolvedValue({ result: 'success' }) + } as any, + transport: {} as any + } + + mcpHub.connections = [mockConnection] + + await expect(mcpHub.callTool('disabled-server', 'some-tool', {})) + .rejects + .toThrow('Server "disabled-server" is disabled and cannot be used') + }) + + it('should prevent reading resources from disabled servers', async () => { + const mockConnection: McpConnection = { + server: { + name: 'disabled-server', + config: '{}', + status: 'connected', + disabled: true + }, + client: { + request: jest.fn() + } as any, + transport: {} as any + } + + mcpHub.connections = [mockConnection] + + await expect(mcpHub.readResource('disabled-server', 'some/uri')) + .rejects + .toThrow('Server "disabled-server" is disabled') + }) + }) + describe('callTool', () => { it('should execute tool successfully', async () => { // Mock the connection with a minimal client implementation diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index e7cfe43..31802b9 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -36,7 +36,9 @@ export interface WebviewMessage { | "openMcpSettings" | "restartMcpServer" | "toggleToolAlwaysAllow" + | "toggleMcpServer" text?: string + disabled?: boolean askResponse?: ClineAskResponse apiConfiguration?: ApiConfiguration images?: string[] diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts index a00b343..7df1415 100644 --- a/src/shared/mcp.ts +++ b/src/shared/mcp.ts @@ -6,6 +6,7 @@ export type McpServer = { tools?: McpTool[] resources?: McpResource[] resourceTemplates?: McpResourceTemplate[] + disabled?: boolean } export type McpTool = { diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index e15c2a1..318cbab 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -189,6 +189,7 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer, alwaysAllowM background: "var(--vscode-textCodeBlock-background)", cursor: server.error ? "default" : "pointer", borderRadius: isExpanded || server.error ? "4px 4px 0 0" : "4px", + opacity: server.disabled ? 0.6 : 1, }} onClick={handleRowClick}> {!server.error && ( @@ -198,6 +199,55 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer, alwaysAllowM /> )} {server.name} +
e.stopPropagation()}> +
{ + vscode.postMessage({ + type: "toggleMcpServer", + serverName: server.name, + disabled: !server.disabled + }); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + vscode.postMessage({ + type: "toggleMcpServer", + serverName: server.name, + disabled: !server.disabled + }); + } + }} + > +
+
+