diff --git a/.changeset/slimy-years-smell.md b/.changeset/slimy-years-smell.md new file mode 100644 index 0000000000..834933c368 --- /dev/null +++ b/.changeset/slimy-years-smell.md @@ -0,0 +1,5 @@ +--- +"roo-cline": minor +--- + +new feature allowing users to toggle whether an individual MCP (Model Context Protocol) tool is included in the context provided to the AI model diff --git a/src/__mocks__/fs/promises.ts b/src/__mocks__/fs/promises.ts index 91e686fb70..63d7a42007 100644 --- a/src/__mocks__/fs/promises.ts +++ b/src/__mocks__/fs/promises.ts @@ -168,6 +168,7 @@ const mockFs = { args: ["test.js"], disabled: false, alwaysAllow: ["existing-tool"], + disabledForPromptTools: [], }, }, }), diff --git a/src/core/prompts/instructions/create-mcp-server.ts b/src/core/prompts/instructions/create-mcp-server.ts index 3d1d2a20cf..062c847355 100644 --- a/src/core/prompts/instructions/create-mcp-server.ts +++ b/src/core/prompts/instructions/create-mcp-server.ts @@ -50,6 +50,7 @@ Common configuration options for both types: - \`disabled\`: (optional) Set to true to temporarily disable the server - \`timeout\`: (optional) Maximum time in seconds to wait for server responses (default: 60) - \`alwaysAllow\`: (optional) Array of tool names that don't require user confirmation +- \`disabledForPromptTools\`: (optional) Array of tool names that don't includes to system prompt and won't be used ### Example Local MCP Server @@ -276,7 +277,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 MCP settings file, you must default any new MCP servers you create to disabled=false and alwaysAllow=[]. +IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false, alwaysAllow=[] and disabledForPromptTools=[]. \`\`\`json { diff --git a/src/core/prompts/sections/mcp-servers.ts b/src/core/prompts/sections/mcp-servers.ts index 2a9b6d148a..0af850cbb1 100644 --- a/src/core/prompts/sections/mcp-servers.ts +++ b/src/core/prompts/sections/mcp-servers.ts @@ -17,6 +17,7 @@ export async function getMcpServersSection( .filter((server) => server.status === "connected") .map((server) => { const tools = server.tools + ?.filter((tool) => tool.enabledForPrompt !== false) ?.map((tool) => { const schemaStr = tool.inputSchema ? ` Input Schema: diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c5433497dc..218616ade3 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -574,6 +574,23 @@ export const webviewMessageHandler = async ( } break } + case "toggleToolEnabledForPrompt": { + try { + await provider + .getMcpHub() + ?.toggleToolEnabledForPrompt( + message.serverName!, + message.source as "global" | "project", + message.toolName!, + Boolean(message.isEnabled), + ) + } catch (error) { + provider.log( + `Failed to toggle enabled for prompt for tool ${message.toolName}: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + } + break + } case "toggleMcpServer": { try { await provider diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 8b1f0ab2ae..4e05a4213b 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -45,6 +45,7 @@ const BaseConfigSchema = z.object({ timeout: z.number().min(1).max(3600).optional().default(60), alwaysAllow: z.array(z.string()).default([]), watchPaths: z.array(z.string()).optional(), // paths to watch for changes and restart server + disabledForPromptTools: z.array(z.string()).default([]), }) // Custom error messages for better user feedback @@ -819,34 +820,39 @@ export class McpHub { const actualSource = connection.server.source || "global" let configPath: string let alwaysAllowConfig: string[] = [] + let disabledForPromptToolsList: string[] = [] // Read from the appropriate config file based on the actual source try { + let serverConfigData: any if (actualSource === "project") { // Get project MCP config path const projectMcpPath = await this.getProjectMcpPath() if (projectMcpPath) { configPath = projectMcpPath const content = await fs.readFile(configPath, "utf-8") - const config = JSON.parse(content) - alwaysAllowConfig = config.mcpServers?.[serverName]?.alwaysAllow || [] + serverConfigData = JSON.parse(content) } } else { // Get global MCP settings path configPath = await this.getMcpSettingsFilePath() const content = await fs.readFile(configPath, "utf-8") - const config = JSON.parse(content) - alwaysAllowConfig = config.mcpServers?.[serverName]?.alwaysAllow || [] + serverConfigData = JSON.parse(content) + } + if (serverConfigData) { + alwaysAllowConfig = serverConfigData.mcpServers?.[serverName]?.alwaysAllow || [] + disabledForPromptToolsList = serverConfigData.mcpServers?.[serverName]?.disabledForPromptTools || [] } } catch (error) { - console.error(`Failed to read alwaysAllow config for ${serverName}:`, error) - // Continue with empty alwaysAllowConfig + console.error(`Failed to read tool configuration for ${serverName}:`, error) + // Continue with empty configs } - // Mark tools as always allowed based on settings + // Mark tools as always allowed and enabled for prompt based on settings const tools = (response?.tools || []).map((tool) => ({ ...tool, alwaysAllow: alwaysAllowConfig.includes(tool.name), + enabledForPrompt: !disabledForPromptToolsList.includes(tool.name), })) return tools @@ -1550,6 +1556,74 @@ export class McpHub { connection.server.tools = await this.fetchToolsList(serverName, source) await this.notifyWebviewOfServerChanges() } + } catch (error) { + this.showErrorMessage( + `Failed to toggle always allow for tool "${toolName}" on server "${serverName}" with source "${source}"`, + error, + ) + throw error + } + } + + async toggleToolEnabledForPrompt( + serverName: string, + source: "global" | "project", + toolName: string, + isEnabled: boolean, + ): Promise { + try { + const connection = this.findConnection(serverName, source) + if (!connection) { + throw new Error(`Server ${serverName} with source ${source} not found`) + } + + let configPath: string + if (source === "project") { + const projectMcpPath = await this.getProjectMcpPath() + if (!projectMcpPath) { + throw new Error("Project MCP configuration file not found") + } + configPath = projectMcpPath + } else { + configPath = await this.getMcpSettingsFilePath() + } + + const normalizedPath = process.platform === "win32" ? configPath.replace(/\\/g, "/") : configPath + const content = await fs.readFile(normalizedPath, "utf-8") + const config = JSON.parse(content) + + if (!config.mcpServers) { + config.mcpServers = {} + } + if (!config.mcpServers[serverName]) { + // Initialize with minimal valid config if server entry doesn't exist + config.mcpServers[serverName] = { + type: "stdio", + command: "echo", + args: ["MCP server not fully configured"], + } + } + if (!config.mcpServers[serverName].disabledForPromptTools) { + config.mcpServers[serverName].disabledForPromptTools = [] + } + + const disabledList = config.mcpServers[serverName].disabledForPromptTools + const toolIndex = disabledList.indexOf(toolName) + + if (!isEnabled && toolIndex === -1) { + // If tool should be disabled (not included in prompt) and is not in disabled list + disabledList.push(toolName) + } else if (isEnabled && toolIndex !== -1) { + // If tool should be enabled (included in prompt) and is in disabled list + disabledList.splice(toolIndex, 1) + } + + await fs.writeFile(normalizedPath, JSON.stringify(config, null, 2)) + + if (connection) { + connection.server.tools = await this.fetchToolsList(serverName, source) + await this.notifyWebviewOfServerChanges() + } } catch (error) { this.showErrorMessage(`Failed to update always allow settings for tool ${toolName}`, error) throw error // Re-throw to ensure the error is properly handled diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index f6f352961c..a9e8c39a7e 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -100,6 +100,7 @@ describe("McpHub", () => { command: "node", args: ["test.js"], alwaysAllow: ["allowed-tool"], + disabledForPromptTools: ["disabled-tool"], }, }, }), @@ -257,6 +258,107 @@ describe("McpHub", () => { }) }) + describe("toggleToolEnabledForPrompt", () => { + it("should add tool to disabledForPromptTools list when enabling", async () => { + const mockConfig = { + mcpServers: { + "test-server": { + type: "stdio", + command: "node", + args: ["test.js"], + disabledForPromptTools: [], + }, + }, + } + + // Mock reading initial config + ;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig)) + + await mcpHub.toggleToolEnabledForPrompt("test-server", "global", "new-tool", false) + + // Verify the config was updated correctly + const writeCalls = (fs.writeFile as jest.Mock).mock.calls + expect(writeCalls.length).toBeGreaterThan(0) + + // Find the write call + const callToUse = writeCalls[writeCalls.length - 1] + expect(callToUse).toBeTruthy() + + // The path might be normalized differently on different platforms, + // so we'll just check that we have a call with valid content + const writtenConfig = JSON.parse(callToUse[1]) + expect(writtenConfig.mcpServers).toBeDefined() + expect(writtenConfig.mcpServers["test-server"]).toBeDefined() + expect(Array.isArray(writtenConfig.mcpServers["test-server"].enabledForPrompt)).toBe(false) + expect(writtenConfig.mcpServers["test-server"].disabledForPromptTools).toContain("new-tool") + }) + + it("should remove tool from disabledForPromptTools list when disabling", async () => { + const mockConfig = { + mcpServers: { + "test-server": { + type: "stdio", + command: "node", + args: ["test.js"], + disabledForPromptTools: ["existing-tool"], + }, + }, + } + + // Mock reading initial config + ;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig)) + + await mcpHub.toggleToolEnabledForPrompt("test-server", "global", "existing-tool", true) + + // Verify the config was updated correctly + const writeCalls = (fs.writeFile as jest.Mock).mock.calls + expect(writeCalls.length).toBeGreaterThan(0) + + // Find the write call + const callToUse = writeCalls[writeCalls.length - 1] + expect(callToUse).toBeTruthy() + + // The path might be normalized differently on different platforms, + // so we'll just check that we have a call with valid content + const writtenConfig = JSON.parse(callToUse[1]) + expect(writtenConfig.mcpServers).toBeDefined() + expect(writtenConfig.mcpServers["test-server"]).toBeDefined() + expect(Array.isArray(writtenConfig.mcpServers["test-server"].enabledForPrompt)).toBe(false) + expect(writtenConfig.mcpServers["test-server"].disabledForPromptTools).not.toContain("existing-tool") + }) + + it("should initialize disabledForPromptTools if it does not exist", async () => { + const mockConfig = { + mcpServers: { + "test-server": { + type: "stdio", + command: "node", + args: ["test.js"], + }, + }, + } + + // Mock reading initial config + ;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig)) + + // Call with false because of "true" is default value + await mcpHub.toggleToolEnabledForPrompt("test-server", "global", "new-tool", false) + + // Verify the config was updated with initialized disabledForPromptTools + // Find the write call with the normalized path + const normalizedSettingsPath = "/mock/settings/path/cline_mcp_settings.json" + const writeCalls = (fs.writeFile as jest.Mock).mock.calls + + // Find the write call with the normalized path + const writeCall = writeCalls.find((call) => call[0] === normalizedSettingsPath) + const callToUse = writeCall || writeCalls[0] + + const writtenConfig = JSON.parse(callToUse[1]) + expect(writtenConfig.mcpServers["test-server"].disabledForPromptTools).toBeDefined() + expect(writtenConfig.mcpServers["test-server"].disabledForPromptTools).toContain("new-tool") + }) + }) + describe("server disabled state", () => { it("should toggle server disabled state", async () => { const mockConfig = { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index cbcf0c10e3..39275bb2d0 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -91,6 +91,7 @@ export interface WebviewMessage { | "restartMcpServer" | "refreshAllMcpServers" | "toggleToolAlwaysAllow" + | "toggleToolEnabledForPrompt" | "toggleMcpServer" | "updateMcpTimeout" | "fuzzyMatchThreshold" @@ -182,6 +183,7 @@ export interface WebviewMessage { serverName?: string toolName?: string alwaysAllow?: boolean + isEnabled?: boolean mode?: Mode promptMode?: PromptMode customPrompt?: PromptComponent diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts index f6c4fe8cc1..ef1d51bad3 100644 --- a/src/shared/mcp.ts +++ b/src/shared/mcp.ts @@ -25,6 +25,7 @@ export type McpTool = { description?: string inputSchema?: object alwaysAllow?: boolean + enabledForPrompt?: boolean } export type McpResource = { diff --git a/webview-ui/src/components/mcp/McpToolRow.tsx b/webview-ui/src/components/mcp/McpToolRow.tsx index 507933ddf1..0cd11bb65f 100644 --- a/webview-ui/src/components/mcp/McpToolRow.tsx +++ b/webview-ui/src/components/mcp/McpToolRow.tsx @@ -25,6 +25,17 @@ const McpToolRow = ({ tool, serverName, serverSource, alwaysAllowMcp }: McpToolR }) } + const handleEnabledForPromptChange = () => { + if (!serverName) return + vscode.postMessage({ + type: "toggleToolEnabledForPrompt", + serverName, + source: serverSource || "global", + toolName: tool.name, + isEnabled: !tool.enabledForPrompt, + }) + } + return (
{tool.name}
- {serverName && alwaysAllowMcp && ( - - {t("mcp:tool.alwaysAllow")} - - )} +
+ {" "} + {/* Wrapper for checkboxes */} + {serverName && ( +
+ +
+ )} + {serverName && alwaysAllowMcp && ( + + {t("mcp:tool.alwaysAllow")} + + )} +
{tool.description && (