Merge pull request #110 from RooVetGit/toggle_mcp_servers

Allow enabling/disabling of MCP servers
This commit is contained in:
Matt Rubens
2024-12-14 11:13:47 -05:00
committed by GitHub
11 changed files with 258 additions and 9 deletions

View File

@@ -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

View File

@@ -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

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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
{

View File

@@ -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":

View File

@@ -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(
{

View File

@@ -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

View File

@@ -36,7 +36,9 @@ export interface WebviewMessage {
| "openMcpSettings"
| "restartMcpServer"
| "toggleToolAlwaysAllow"
| "toggleMcpServer"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
apiConfiguration?: ApiConfiguration
images?: string[]

View File

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

View File

@@ -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",