From 08c501213179e32b1f39ae275082c10a79ca7656 Mon Sep 17 00:00:00 2001 From: Stefan Vetter Date: Tue, 7 Apr 2026 09:21:23 +0200 Subject: [PATCH] feat: add MCP filter configuration UI for per-mode MCP server/tool filtering Adds UI components to the mode editor for configuring which MCP servers and tools are available per mode, completing the MCP filtering feature. New components: - McpFilterConfig.tsx: Main panel with default policy selector (allow/deny) and server list, shown when 'mcp' group is enabled in mode editor - McpServerFilterRow.tsx: Per-server row with enable/disable toggle and expandable tool-level filtering (allowlist/blocklist/allow-all modes) ModesView.tsx integration: - Edit mode: McpFilterConfig renders below mcp checkbox when checked - Read-only mode: Shows filter summary via McpFilterConfig - Helper functions getMcpOptionsFromGroups/updateMcpOptionsInGroups for extracting/updating MCP tuple options in groups array - groupOptionsCache sync ensures filter config survives toggle cycles Version bump: 3.51.1 -> 3.52.0 --- src/package.json | 2 +- .../src/components/modes/McpFilterConfig.tsx | 167 +++++++++ .../components/modes/McpServerFilterRow.tsx | 324 ++++++++++++++++++ webview-ui/src/components/modes/ModesView.tsx | 142 ++++++-- .../__tests__/McpServerFilterRow.spec.tsx | 275 +++++++++++++++ 5 files changed, 875 insertions(+), 35 deletions(-) create mode 100644 webview-ui/src/components/modes/McpFilterConfig.tsx create mode 100644 webview-ui/src/components/modes/McpServerFilterRow.tsx create mode 100644 webview-ui/src/components/modes/__tests__/McpServerFilterRow.spec.tsx diff --git a/src/package.json b/src/package.json index 7c4889abd8..e7e198ea69 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.51.1", + "version": "3.52.0", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", diff --git a/webview-ui/src/components/modes/McpFilterConfig.tsx b/webview-ui/src/components/modes/McpFilterConfig.tsx new file mode 100644 index 0000000000..59f82bd79f --- /dev/null +++ b/webview-ui/src/components/modes/McpFilterConfig.tsx @@ -0,0 +1,167 @@ +import { useCallback } from "react" + +import type { McpServer, McpGroupOptions, McpServerFilter } from "@roo-code/types" + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui" +import { McpServerFilterRow } from "./McpServerFilterRow" + +export interface McpFilterConfigProps { + mcpServers: McpServer[] + mcpGroupOptions: McpGroupOptions | undefined + onOptionsChange: (options: McpGroupOptions | undefined) => void + isEditing: boolean +} + +function getDefaultPolicy(options: McpGroupOptions | undefined): "allow" | "deny" { + if (options && options.mcpDefaultPolicy) { + return options.mcpDefaultPolicy + } + return "allow" +} + +function hasAnyFilters(options: McpGroupOptions | undefined): boolean { + if (!options) { + return false + } + if (options.mcpDefaultPolicy && options.mcpDefaultPolicy !== "allow") { + return true + } + if (options.mcpServers && Object.keys(options.mcpServers).length > 0) { + return true + } + return false +} + +function getServerFilter(options: McpGroupOptions | undefined, serverName: string): McpServerFilter | undefined { + if (!options || !options.mcpServers) { + return undefined + } + return options.mcpServers[serverName] +} + +function buildCleanOptions( + policy: "allow" | "deny", + servers: Record | undefined, +): McpGroupOptions | undefined { + var hasServers = servers && Object.keys(servers).length > 0 + if (policy === "allow" && !hasServers) { + return undefined + } + var result: McpGroupOptions = {} + if (policy !== "allow") { + result.mcpDefaultPolicy = policy + } + if (hasServers) { + result.mcpServers = servers + } + return result +} + +export function McpFilterConfig({ mcpServers, mcpGroupOptions, onOptionsChange, isEditing }: McpFilterConfigProps) { + var policy = getDefaultPolicy(mcpGroupOptions) + var serverCount = mcpServers.length + + var handlePolicyChange = useCallback( + function (newPolicy: string) { + var typedPolicy = newPolicy as "allow" | "deny" + var currentServers = mcpGroupOptions?.mcpServers + var updated = buildCleanOptions(typedPolicy, currentServers) + onOptionsChange(updated) + }, + [mcpGroupOptions, onOptionsChange], + ) + + var handleServerFilterChange = useCallback( + function (serverName: string, filter: McpServerFilter | undefined) { + var currentServers = mcpGroupOptions?.mcpServers || {} + var updatedServers: Record + + if (filter) { + updatedServers = { ...currentServers, [serverName]: filter } + } else { + updatedServers = { ...currentServers } + delete updatedServers[serverName] + } + + var currentPolicy = getDefaultPolicy(mcpGroupOptions) + var updated = buildCleanOptions(currentPolicy, updatedServers) + onOptionsChange(updated) + }, + [mcpGroupOptions, onOptionsChange], + ) + + // Read-only mode + if (!isEditing) { + return ( +
+ {!hasAnyFilters(mcpGroupOptions) ? ( + All servers and tools allowed + ) : ( +
+
{"Default policy: " + policy}
+ {mcpGroupOptions?.mcpServers && Object.keys(mcpGroupOptions.mcpServers).length > 0 && ( +
+ {Object.keys(mcpGroupOptions.mcpServers).length + " server(s) with custom filters"} +
+ )} +
+ )} +
+ ) + } + + return ( +
+ {/* Default Policy selector */} +
+ + +
+ + {/* Server list */} +
+
+ MCP Servers + + {serverCount + (serverCount === 1 ? " server" : " servers")} + +
+ + {serverCount === 0 ? ( +
+ No MCP servers connected +
+ ) : ( +
+ {mcpServers.map(function (server) { + var tools = (server.tools || []).map(function (t) { + return { name: t.name, description: t.description } + }) + return ( + + ) + })} +
+ )} +
+
+ ) +} diff --git a/webview-ui/src/components/modes/McpServerFilterRow.tsx b/webview-ui/src/components/modes/McpServerFilterRow.tsx new file mode 100644 index 0000000000..b88cb901a6 --- /dev/null +++ b/webview-ui/src/components/modes/McpServerFilterRow.tsx @@ -0,0 +1,324 @@ +import { useState, useCallback } from "react" + +import type { McpServerFilter } from "@roo-code/types" + +import { Checkbox } from "@/components/ui/checkbox" +import { ToggleSwitch } from "@/components/ui/toggle-switch" + +type FilterMode = "allowAll" | "allowlist" | "blocklist" + +export interface McpServerFilterRowProps { + serverName: string + serverStatus: string + availableTools: Array<{ name: string; description?: string }> + filter: McpServerFilter | undefined + onFilterChange: (serverName: string, filter: McpServerFilter | undefined) => void + isEditing: boolean +} + +function getFilterMode(filter: McpServerFilter | undefined): FilterMode { + if (!filter) { + return "allowAll" + } + if (filter.allowedTools) { + return "allowlist" + } + if (filter.disabledTools) { + return "blocklist" + } + return "allowAll" +} + +function isToolEnabled(toolName: string, filter: McpServerFilter | undefined): boolean { + if (!filter) { + return true + } + if (filter.allowedTools) { + return filter.allowedTools.includes(toolName) + } + if (filter.disabledTools) { + return !filter.disabledTools.includes(toolName) + } + return true +} + +function getEnabledToolCount(tools: Array<{ name: string }>, filter: McpServerFilter | undefined): number { + return tools.filter(function (t) { + return isToolEnabled(t.name, filter) + }).length +} + +function getStatusDotClass(status: string, isDisabled: boolean): string { + if (isDisabled) { + return "bg-vscode-descriptionForeground" + } + if (status === "connected") { + return "bg-vscode-charts-green" + } + if (status === "connecting") { + return "bg-vscode-charts-yellow" + } + return "bg-vscode-descriptionForeground" +} + +const FILTER_MODE_LABELS: Record = { + allowAll: "Allow All", + allowlist: "Allowlist", + blocklist: "Blocklist", +} + +export function McpServerFilterRow({ + serverName, + serverStatus, + availableTools, + filter, + onFilterChange, + isEditing, +}: McpServerFilterRowProps) { + const [isExpanded, setIsExpanded] = useState(false) + + const isDisabled = filter?.disabled === true + const filterMode = getFilterMode(filter) + const enabledCount = getEnabledToolCount(availableTools, filter) + const totalCount = availableTools.length + + const handleToggleServer = useCallback( + function () { + if (isDisabled) { + // Re-enable: remove disabled flag, keep other filter settings + var updated: McpServerFilter | undefined = filter ? { ...filter, disabled: undefined } : undefined + // Clean up empty object + if (updated && !updated.allowedTools && !updated.disabledTools && !updated.disabled) { + updated = undefined + } + onFilterChange(serverName, updated) + } else { + // Disable the server + var newFilter: McpServerFilter = filter ? { ...filter, disabled: true } : { disabled: true } + onFilterChange(serverName, newFilter) + } + }, + [isDisabled, filter, serverName, onFilterChange], + ) + + var handleToggleExpand = useCallback( + function () { + if (!isDisabled) { + setIsExpanded(function (prev) { + return !prev + }) + } + }, + [isDisabled], + ) + + var handleFilterModeChange = useCallback( + function (newMode: FilterMode) { + if (newMode === "allowAll") { + var cleaned: McpServerFilter | undefined = filter ? { disabled: filter.disabled } : undefined + if (cleaned && !cleaned.disabled) { + cleaned = undefined + } + onFilterChange(serverName, cleaned) + } else if (newMode === "allowlist") { + // Start allowlist with all tools included + var allNames = availableTools.map(function (t) { + return t.name + }) + onFilterChange(serverName, { + ...filter, + allowedTools: allNames, + disabledTools: undefined, + }) + } else { + // Start blocklist with none disabled + onFilterChange(serverName, { + ...filter, + disabledTools: [], + allowedTools: undefined, + }) + } + }, + [filter, serverName, availableTools, onFilterChange], + ) + + var handleToggleTool = useCallback( + function (toolName: string) { + var currentlyEnabled = isToolEnabled(toolName, filter) + + if (filterMode === "allowlist") { + var currentAllowed = filter?.allowedTools || [] + var newAllowed = currentlyEnabled + ? currentAllowed.filter(function (n) { + return n !== toolName + }) + : currentAllowed.concat([toolName]) + onFilterChange(serverName, { + ...filter, + allowedTools: newAllowed, + }) + } else if (filterMode === "blocklist") { + var currentDisabled = filter?.disabledTools || [] + var newDisabled = currentlyEnabled + ? currentDisabled.concat([toolName]) + : currentDisabled.filter(function (n) { + return n !== toolName + }) + onFilterChange(serverName, { + ...filter, + disabledTools: newDisabled, + }) + } + }, + [filter, filterMode, serverName, onFilterChange], + ) + + // Read-only mode + if (!isEditing) { + return ( +
+
+ {serverName} + + {isDisabled ? "disabled" : enabledCount + " of " + totalCount + " tools allowed"} + +
+ ) + } + + // Editable mode + return ( +
+ {/* Server header row */} +
+ {/* Chevron */} + {!isDisabled && ( + + )} + + {/* Status dot */} +
+ + {/* Server name */} + {serverName} + + {/* Tool count */} + {totalCount + " tools"} + + {/* Spacer */} +
+ + {/* Toggle switch */} +
+ +
+
+ + {/* Expandable tool list */} + {!isDisabled && isExpanded && ( +
+ {/* Filter mode selector */} +
+ Mode: + {(["allowAll", "allowlist", "blocklist"] as FilterMode[]).map(function (mode) { + return ( + + ) + })} +
+ + {/* Tool rows */} + {filterMode !== "allowAll" && availableTools.length > 0 && ( +
+ {availableTools.map(function (tool) { + var enabled = isToolEnabled(tool.name, filter) + return ( +
+ + + {tool.name} + + {tool.description && ( + + {tool.description} + + )} +
+ ) + })} +
+ )} + + {/* Allow all message */} + {filterMode === "allowAll" && ( +
+ All tools are enabled for this mode. +
+ )} + + {/* No tools message */} + {availableTools.length === 0 && ( +
No tools available.
+ )} +
+ )} +
+ ) +} + +export default McpServerFilterRow diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index 4da0b17ec0..5713a29355 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -10,7 +10,7 @@ import { import { Trans } from "react-i18next" import { ChevronDown, X, Upload, Download } from "lucide-react" -import { ModeConfig, GroupEntry, PromptComponent, ToolGroup, modeConfigSchema } from "@roo-code/types" +import { ModeConfig, GroupEntry, McpGroupOptions, PromptComponent, ToolGroup, modeConfigSchema } from "@roo-code/types" import { Mode, @@ -25,6 +25,7 @@ import { import { TOOL_GROUPS } from "@roo/tools" import { syncCacheFromGroups, removeGroupWithCache, addGroupWithCache } from "./groupOptionsCache" +import { McpFilterConfig } from "./McpFilterConfig" import { vscode } from "@src/utils/vscode" import { buildDocLink } from "@src/utils/docLinks" @@ -65,6 +66,28 @@ function getGroupName(group: GroupEntry): ToolGroup { return Array.isArray(group) ? group[0] : group } +// Extract MCP options from a groups array +function getMcpOptionsFromGroups(groups: GroupEntry[]): McpGroupOptions | undefined { + for (var i = 0; i < groups.length; i++) { + var entry = groups[i] + if (Array.isArray(entry) && entry[0] === "mcp" && entry[1]) { + return entry[1] as McpGroupOptions + } + } + return undefined +} + +// Update MCP options in a groups array +function updateMcpOptionsInGroups(groups: GroupEntry[], options: McpGroupOptions | undefined): GroupEntry[] { + return groups.map(function (entry) { + var name = typeof entry === "string" ? entry : entry[0] + if (name === "mcp") { + return options ? (["mcp", options] as GroupEntry) : "mcp" + } + return entry + }) +} + const ModesView = () => { const { t } = useAppTranslation() @@ -76,6 +99,7 @@ const ModesView = () => { customInstructions, setCustomInstructions, customModes, + mcpServers, } = useExtensionState() // Use a local state to track the visually active mode @@ -1140,40 +1164,75 @@ const ModesView = () => {
)} {isToolsEditMode && findModeBySlug(visualMode, customModes) ? ( -
- {availableGroups.map((group) => { - const currentMode = getCurrentMode() - const isCustomMode = findModeBySlug(visualMode, customModes) - const customMode = isCustomMode - const isGroupEnabled = isCustomMode - ? customMode?.groups?.some((g) => getGroupName(g) === group) - : currentMode?.groups?.some((g) => getGroupName(g) === group) + <> +
+ {availableGroups.map((group) => { + const currentMode = getCurrentMode() + const isCustomMode = findModeBySlug(visualMode, customModes) + const customMode = isCustomMode + const isGroupEnabled = isCustomMode + ? customMode?.groups?.some((g) => getGroupName(g) === group) + : currentMode?.groups?.some((g) => getGroupName(g) === group) + return ( + + {t(`prompts:tools.toolNames.${group}`)} + {group === "edit" && ( +
+ {t("prompts:tools.allowedFiles")}{" "} + {(() => { + const currentMode = getCurrentMode() + const editGroup = currentMode?.groups?.find( + (g) => + Array.isArray(g) && + g[0] === "edit" && + g[1]?.fileRegex, + ) + if (!Array.isArray(editGroup)) return t("prompts:allFiles") + return ( + editGroup[1].description || + `/${editGroup[1].fileRegex}/` + ) + })()} +
+ )} +
+ ) + })} +
+ {(() => { + const customMode = findModeBySlug(visualMode, customModes) + const isMcpEnabled = customMode?.groups?.some((g) => getGroupName(g) === "mcp") + if (!isMcpEnabled || !customMode) return null + const mcpOptions = getMcpOptionsFromGroups(customMode.groups || []) return ( - - {t(`prompts:tools.toolNames.${group}`)} - {group === "edit" && ( -
- {t("prompts:tools.allowedFiles")}{" "} - {(() => { - const currentMode = getCurrentMode() - const editGroup = currentMode?.groups?.find( - (g) => - Array.isArray(g) && g[0] === "edit" && g[1]?.fileRegex, - ) - if (!Array.isArray(editGroup)) return t("prompts:allFiles") - return editGroup[1].description || `/${editGroup[1].fileRegex}/` - })()} -
- )} -
+ { + const oldGroups = customMode.groups || [] + const newGroups = updateMcpOptionsInGroups(oldGroups, options) + // Also update the cache so toggle off/on preserves config + if (options) { + groupOptionsCache.current.set("mcp", options) + } else { + groupOptionsCache.current.delete("mcp") + } + updateCustomMode(customMode.slug, { + ...customMode, + groups: newGroups, + source: customMode.source || "global", + }) + }} + isEditing={true} + /> ) - })} -
+ })()} + ) : (
{(() => { @@ -1190,13 +1249,28 @@ const ModesView = () => { const groupName = getGroupName(group) const displayName = t(`prompts:tools.toolNames.${groupName}`) if (Array.isArray(group) && group[1]?.fileRegex) { - const description = group[1].description || `/${group[1].fileRegex}/` - return `${displayName} (${description})` + const description = + group[1].description || "/" + group[1].fileRegex + "/" + return displayName + " (" + description + ")" } return displayName }) .join(", ") })()} + {(() => { + const currentMode = getCurrentMode() + const mcpOptions = getMcpOptionsFromGroups(currentMode?.groups || []) + const isMcpEnabled = currentMode?.groups?.some((g) => getGroupName(g) === "mcp") + if (!isMcpEnabled) return null + return ( + {}} + isEditing={false} + /> + ) + })()}
)}
diff --git a/webview-ui/src/components/modes/__tests__/McpServerFilterRow.spec.tsx b/webview-ui/src/components/modes/__tests__/McpServerFilterRow.spec.tsx new file mode 100644 index 0000000000..22760c8ea8 --- /dev/null +++ b/webview-ui/src/components/modes/__tests__/McpServerFilterRow.spec.tsx @@ -0,0 +1,275 @@ +import React from "react" +import { render, fireEvent, screen } from "@/utils/test-utils" + +import { McpServerFilterRow } from "../McpServerFilterRow" +import type { McpServerFilterRowProps } from "../McpServerFilterRow" + +vi.mock("@/components/ui/checkbox", function () { + return { + Checkbox: function MockCheckbox({ checked, onCheckedChange, "aria-label": ariaLabel }: any) { + return ( + + ) + }, + checkboxVariants: function () { + return "" + }, + } +}) + +vi.mock("@/components/ui/toggle-switch", function () { + return { + ToggleSwitch: function MockToggleSwitch({ + checked, + onChange, + "aria-label": ariaLabel, + "data-testid": testId, + }: any) { + return ( +