Browse Source

Merge pull request #110 from RooVetGit/toggle_mcp_servers

Allow enabling/disabling of MCP servers
Matt Rubens 1 year ago
parent
commit
85acffdfa7

+ 4 - 0
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

+ 1 - 0
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
 

+ 2 - 2
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",

+ 1 - 1
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",

+ 1 - 1
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
 {

+ 11 - 0
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":

+ 88 - 5
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<string> {
@@ -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<void> {
+		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<McpResourceResponse> {
 		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(
 			{

+ 97 - 0
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

+ 2 - 0
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[]

+ 1 - 0
src/shared/mcp.ts

@@ -6,6 +6,7 @@ export type McpServer = {
 	tools?: McpTool[]
 	resources?: McpResource[]
 	resourceTemplates?: McpResourceTemplate[]
+	disabled?: boolean
 }
 
 export type McpTool = {

+ 50 - 0
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
 					/>
 				)}
 				<span style={{ flex: 1 }}>{server.name}</span>
+				<div
+					style={{ display: "flex", alignItems: "center", marginRight: "8px" }}
+					onClick={(e) => e.stopPropagation()}>
+					<div
+						role="switch"
+						aria-checked={!server.disabled}
+						tabIndex={0}
+						style={{
+							width: "20px",
+							height: "10px",
+							backgroundColor: server.disabled ?
+								"var(--vscode-titleBar-inactiveForeground)" :
+								"var(--vscode-button-background)",
+							borderRadius: "5px",
+							position: "relative",
+							cursor: "pointer",
+							transition: "background-color 0.2s",
+							opacity: server.disabled ? 0.4 : 0.8,
+						}}
+						onClick={() => {
+							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
+								});
+							}
+						}}
+					>
+						<div style={{
+							width: "6px",
+							height: "6px",
+							backgroundColor: "var(--vscode-titleBar-activeForeground)",
+							borderRadius: "50%",
+							position: "absolute",
+							top: "2px",
+							left: server.disabled ? "2px" : "12px",
+							transition: "left 0.2s",
+						}} />
+					</div>
+				</div>
 				<div
 					style={{
 						width: "8px",