mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-01 02:01:27 +00:00
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
This commit is contained in:
parent
79de65a8f5
commit
08c5012131
5 changed files with 875 additions and 35 deletions
|
|
@ -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",
|
||||
|
|
|
|||
167
webview-ui/src/components/modes/McpFilterConfig.tsx
Normal file
167
webview-ui/src/components/modes/McpFilterConfig.tsx
Normal file
|
|
@ -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<string, McpServerFilter> | 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<string, McpServerFilter>
|
||||
|
||||
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 (
|
||||
<div
|
||||
data-testid="mcp-filter-config-readonly"
|
||||
className="mt-2 ml-5 text-sm text-vscode-descriptionForeground">
|
||||
{!hasAnyFilters(mcpGroupOptions) ? (
|
||||
<span>All servers and tools allowed</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div>{"Default policy: " + policy}</div>
|
||||
{mcpGroupOptions?.mcpServers && Object.keys(mcpGroupOptions.mcpServers).length > 0 && (
|
||||
<div>
|
||||
{Object.keys(mcpGroupOptions.mcpServers).length + " server(s) with custom filters"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="mcp-filter-config" className="mt-3 ml-5 border border-vscode-panel-border rounded p-3">
|
||||
{/* Default Policy selector */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-vscode-foreground mb-1">Default Policy</label>
|
||||
<Select value={policy} onValueChange={handlePolicyChange}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow All by Default</SelectItem>
|
||||
<SelectItem value="deny">Deny All by Default</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Server list */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-medium text-vscode-foreground">MCP Servers</span>
|
||||
<span className="text-xs text-vscode-descriptionForeground">
|
||||
{serverCount + (serverCount === 1 ? " server" : " servers")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{serverCount === 0 ? (
|
||||
<div className="text-xs text-vscode-descriptionForeground italic py-2">
|
||||
No MCP servers connected
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{mcpServers.map(function (server) {
|
||||
var tools = (server.tools || []).map(function (t) {
|
||||
return { name: t.name, description: t.description }
|
||||
})
|
||||
return (
|
||||
<McpServerFilterRow
|
||||
key={server.name}
|
||||
serverName={server.name}
|
||||
serverStatus={server.status}
|
||||
availableTools={tools}
|
||||
filter={getServerFilter(mcpGroupOptions, server.name)}
|
||||
onFilterChange={handleServerFilterChange}
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
324
webview-ui/src/components/modes/McpServerFilterRow.tsx
Normal file
324
webview-ui/src/components/modes/McpServerFilterRow.tsx
Normal file
|
|
@ -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<FilterMode, string> = {
|
||||
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 (
|
||||
<div
|
||||
data-testid={"mcp-server-filter-row-" + serverName}
|
||||
className="flex items-center gap-2 py-1.5 px-2 rounded">
|
||||
<div className={"w-2 h-2 rounded-full flex-shrink-0 " + getStatusDotClass(serverStatus, isDisabled)} />
|
||||
<span className="font-medium text-vscode-foreground text-sm">{serverName}</span>
|
||||
<span className="text-xs text-vscode-descriptionForeground ml-auto">
|
||||
{isDisabled ? "disabled" : enabledCount + " of " + totalCount + " tools allowed"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Editable mode
|
||||
return (
|
||||
<div
|
||||
data-testid={"mcp-server-filter-row-" + serverName}
|
||||
className="rounded border border-vscode-panel-border overflow-hidden">
|
||||
{/* Server header row */}
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 px-3 py-2 bg-vscode-textCodeBlock-background" +
|
||||
(isDisabled ? " opacity-60" : "") +
|
||||
(!isDisabled ? " cursor-pointer" : "")
|
||||
}
|
||||
onClick={handleToggleExpand}
|
||||
data-testid={"mcp-server-header-" + serverName}>
|
||||
{/* Chevron */}
|
||||
{!isDisabled && (
|
||||
<span
|
||||
className={
|
||||
"codicon codicon-chevron-" + (isExpanded ? "down" : "right") + " text-xs flex-shrink-0"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status dot */}
|
||||
<div className={"w-2 h-2 rounded-full flex-shrink-0 " + getStatusDotClass(serverStatus, isDisabled)} />
|
||||
|
||||
{/* Server name */}
|
||||
<span className="font-bold text-vscode-foreground text-sm truncate">{serverName}</span>
|
||||
|
||||
{/* Tool count */}
|
||||
<span className="text-xs text-vscode-descriptionForeground ml-1">{totalCount + " tools"}</span>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Toggle switch */}
|
||||
<div
|
||||
onClick={function (e) {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className="flex-shrink-0">
|
||||
<ToggleSwitch
|
||||
checked={!isDisabled}
|
||||
onChange={handleToggleServer}
|
||||
size="medium"
|
||||
aria-label={"Toggle " + serverName + " server"}
|
||||
data-testid={"mcp-server-toggle-" + serverName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable tool list */}
|
||||
{!isDisabled && isExpanded && (
|
||||
<div className="px-3 py-2 border-t border-vscode-panel-border">
|
||||
{/* Filter mode selector */}
|
||||
<div className="flex items-center gap-1 mb-2" data-testid={"mcp-filter-mode-" + serverName}>
|
||||
<span className="text-xs text-vscode-descriptionForeground mr-2">Mode:</span>
|
||||
{(["allowAll", "allowlist", "blocklist"] as FilterMode[]).map(function (mode) {
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={
|
||||
"text-xs px-2 py-0.5 rounded border " +
|
||||
(filterMode === mode
|
||||
? "bg-vscode-button-background text-vscode-button-foreground border-vscode-button-background"
|
||||
: "bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground border-vscode-panel-border")
|
||||
}
|
||||
onClick={function (e) {
|
||||
e.stopPropagation()
|
||||
handleFilterModeChange(mode)
|
||||
}}
|
||||
data-testid={"mcp-filter-mode-btn-" + mode}>
|
||||
{FILTER_MODE_LABELS[mode]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tool rows */}
|
||||
{filterMode !== "allowAll" && availableTools.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{availableTools.map(function (tool) {
|
||||
var enabled = isToolEnabled(tool.name, filter)
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="flex items-center gap-2 py-1"
|
||||
data-testid={"mcp-tool-filter-" + tool.name}>
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onCheckedChange={function () {
|
||||
handleToggleTool(tool.name)
|
||||
}}
|
||||
aria-label={(enabled ? "Disable" : "Enable") + " tool " + tool.name}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
"text-sm " +
|
||||
(enabled
|
||||
? "text-vscode-foreground"
|
||||
: "text-vscode-descriptionForeground opacity-60")
|
||||
}>
|
||||
{tool.name}
|
||||
</span>
|
||||
{tool.description && (
|
||||
<span className="text-xs text-vscode-descriptionForeground truncate ml-1">
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allow all message */}
|
||||
{filterMode === "allowAll" && (
|
||||
<div className="text-xs text-vscode-descriptionForeground py-1">
|
||||
All tools are enabled for this mode.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No tools message */}
|
||||
{availableTools.length === 0 && (
|
||||
<div className="text-xs text-vscode-descriptionForeground py-1">No tools available.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default McpServerFilterRow
|
||||
|
|
@ -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 = () => {
|
|||
</div>
|
||||
)}
|
||||
{isToolsEditMode && findModeBySlug(visualMode, customModes) ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
|
||||
{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)
|
||||
<>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
|
||||
{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 (
|
||||
<VSCodeCheckbox
|
||||
key={group}
|
||||
checked={isGroupEnabled}
|
||||
onChange={handleGroupChange(group, Boolean(isCustomMode), customMode)}
|
||||
disabled={!isCustomMode}>
|
||||
{t(`prompts:tools.toolNames.${group}`)}
|
||||
{group === "edit" && (
|
||||
<div className="text-xs text-vscode-descriptionForeground mt-0.5">
|
||||
{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}/`
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</VSCodeCheckbox>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{(() => {
|
||||
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 (
|
||||
<VSCodeCheckbox
|
||||
key={group}
|
||||
checked={isGroupEnabled}
|
||||
onChange={handleGroupChange(group, Boolean(isCustomMode), customMode)}
|
||||
disabled={!isCustomMode}>
|
||||
{t(`prompts:tools.toolNames.${group}`)}
|
||||
{group === "edit" && (
|
||||
<div className="text-xs text-vscode-descriptionForeground mt-0.5">
|
||||
{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}/`
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</VSCodeCheckbox>
|
||||
<McpFilterConfig
|
||||
mcpServers={mcpServers || []}
|
||||
mcpGroupOptions={mcpOptions}
|
||||
onOptionsChange={(options) => {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
})()}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-vscode-foreground mb-2 leading-relaxed">
|
||||
{(() => {
|
||||
|
|
@ -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 (
|
||||
<McpFilterConfig
|
||||
mcpServers={mcpServers || []}
|
||||
mcpGroupOptions={mcpOptions}
|
||||
onOptionsChange={() => {}}
|
||||
isEditing={false}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
onChange={function () {
|
||||
onCheckedChange(!checked)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
checkboxVariants: function () {
|
||||
return ""
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/components/ui/toggle-switch", function () {
|
||||
return {
|
||||
ToggleSwitch: function MockToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
"aria-label": ariaLabel,
|
||||
"data-testid": testId,
|
||||
}: any) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
onClick={onChange}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
var mockTools = [
|
||||
{ name: "tool-a", description: "First tool" },
|
||||
{ name: "tool-b", description: "Second tool" },
|
||||
{ name: "tool-c" },
|
||||
]
|
||||
|
||||
function renderRow(overrides: Partial<McpServerFilterRowProps> = {}) {
|
||||
var defaultProps: McpServerFilterRowProps = {
|
||||
serverName: "test-server",
|
||||
serverStatus: "connected",
|
||||
availableTools: mockTools,
|
||||
filter: undefined,
|
||||
onFilterChange: vi.fn(),
|
||||
isEditing: true,
|
||||
...overrides,
|
||||
}
|
||||
return {
|
||||
...render(<McpServerFilterRow {...defaultProps} />),
|
||||
onFilterChange: defaultProps.onFilterChange as ReturnType<typeof vi.fn>,
|
||||
}
|
||||
}
|
||||
|
||||
describe("McpServerFilterRow", function () {
|
||||
beforeEach(function () {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("read-only mode", function () {
|
||||
it("renders server name and summary", function () {
|
||||
renderRow({ isEditing: false })
|
||||
expect(screen.getByText("test-server")).toBeInTheDocument()
|
||||
expect(screen.getByText("3 of 3 tools allowed")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows disabled label when server is disabled", function () {
|
||||
renderRow({ isEditing: false, filter: { disabled: true } })
|
||||
expect(screen.getByText("disabled")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows correct count with allowlist filter", function () {
|
||||
renderRow({ isEditing: false, filter: { allowedTools: ["tool-a"] } })
|
||||
expect(screen.getByText("1 of 3 tools allowed")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows correct count with blocklist filter", function () {
|
||||
renderRow({ isEditing: false, filter: { disabledTools: ["tool-b"] } })
|
||||
expect(screen.getByText("2 of 3 tools allowed")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("server header", function () {
|
||||
it("renders server name bold in edit mode", function () {
|
||||
renderRow()
|
||||
expect(screen.getByText("test-server")).toHaveClass("font-bold")
|
||||
})
|
||||
|
||||
it("renders tool count", function () {
|
||||
renderRow()
|
||||
expect(screen.getByText("3 tools")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows green dot when connected", function () {
|
||||
var { container } = renderRow({ serverStatus: "connected" })
|
||||
expect(container.querySelector(".bg-vscode-charts-green")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows yellow dot when connecting", function () {
|
||||
var { container } = renderRow({ serverStatus: "connecting" })
|
||||
expect(container.querySelector(".bg-vscode-charts-yellow")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows gray dot when disconnected", function () {
|
||||
var { container } = renderRow({ serverStatus: "disconnected" })
|
||||
expect(container.querySelector(".bg-vscode-descriptionForeground")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("toggle checked when server enabled", function () {
|
||||
renderRow({ filter: undefined })
|
||||
var toggle = screen.getByRole("switch", { name: "Toggle test-server server" })
|
||||
expect(toggle).toHaveAttribute("aria-checked", "true")
|
||||
})
|
||||
|
||||
it("toggle unchecked when server disabled", function () {
|
||||
renderRow({ filter: { disabled: true } })
|
||||
var toggle = screen.getByRole("switch", { name: "Toggle test-server server" })
|
||||
expect(toggle).toHaveAttribute("aria-checked", "false")
|
||||
})
|
||||
})
|
||||
|
||||
describe("toggle server", function () {
|
||||
it("disables server on toggle when enabled", function () {
|
||||
var { onFilterChange } = renderRow({ filter: undefined })
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Toggle test-server server" }))
|
||||
expect(onFilterChange).toHaveBeenCalledWith("test-server", { disabled: true })
|
||||
})
|
||||
|
||||
it("enables server on toggle when disabled", function () {
|
||||
var { onFilterChange } = renderRow({ filter: { disabled: true } })
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Toggle test-server server" }))
|
||||
expect(onFilterChange).toHaveBeenCalledWith("test-server", undefined)
|
||||
})
|
||||
|
||||
it("preserves allowedTools when re-enabling", function () {
|
||||
var { onFilterChange } = renderRow({
|
||||
filter: { disabled: true, allowedTools: ["tool-a"] },
|
||||
})
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Toggle test-server server" }))
|
||||
expect(onFilterChange).toHaveBeenCalledWith("test-server", {
|
||||
disabled: undefined,
|
||||
allowedTools: ["tool-a"],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("expand/collapse", function () {
|
||||
it("does not show tool list by default", function () {
|
||||
renderRow()
|
||||
expect(screen.queryByTestId("mcp-filter-mode-test-server")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows tool list when header is clicked", function () {
|
||||
renderRow()
|
||||
fireEvent.click(screen.getByTestId("mcp-server-header-test-server"))
|
||||
expect(screen.getByTestId("mcp-filter-mode-test-server")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("does not expand when server is disabled", function () {
|
||||
renderRow({ filter: { disabled: true } })
|
||||
var row = screen.getByTestId("mcp-server-filter-row-test-server")
|
||||
expect(row.querySelector(".codicon-chevron-right")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("filter mode selector", function () {
|
||||
function expandRow(overrides: Partial<McpServerFilterRowProps> = {}) {
|
||||
var result = renderRow(overrides)
|
||||
fireEvent.click(screen.getByTestId("mcp-server-header-test-server"))
|
||||
return result
|
||||
}
|
||||
|
||||
it("defaults to Allow All mode", function () {
|
||||
expandRow()
|
||||
expect(screen.getByText("All tools are enabled for this mode.")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("switches to allowlist mode", function () {
|
||||
var { onFilterChange } = expandRow()
|
||||
fireEvent.click(screen.getByTestId("mcp-filter-mode-btn-allowlist"))
|
||||
expect(onFilterChange).toHaveBeenCalledWith("test-server", {
|
||||
allowedTools: ["tool-a", "tool-b", "tool-c"],
|
||||
disabledTools: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("switches to blocklist mode", function () {
|
||||
var { onFilterChange } = expandRow()
|
||||
fireEvent.click(screen.getByTestId("mcp-filter-mode-btn-blocklist"))
|
||||
expect(onFilterChange).toHaveBeenCalledWith("test-server", {
|
||||
disabledTools: [],
|
||||
allowedTools: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("switches back to allow all mode", function () {
|
||||
var { onFilterChange } = expandRow({ filter: { allowedTools: ["tool-a"] } })
|
||||
fireEvent.click(screen.getByTestId("mcp-filter-mode-btn-allowAll"))
|
||||
expect(onFilterChange).toHaveBeenCalledWith("test-server", undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool checkboxes in allowlist mode", function () {
|
||||
it("renders checkboxes for each tool", function () {
|
||||
renderRow({ filter: { allowedTools: ["tool-a", "tool-b"] } })
|
||||
fireEvent.click(screen.getByTestId("mcp-server-header-test-server"))
|
||||
expect(screen.getByTestId("mcp-tool-filter-tool-a")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("mcp-tool-filter-tool-b")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("mcp-tool-filter-tool-c")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("removes tool from allowlist when unchecked", function () {
|
||||
var { onFilterChange } = renderRow({ filter: { allowedTools: ["tool-a", "tool-b"] } })
|
||||
fireEvent.click(screen.getByTestId("mcp-server-header-test-server"))
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Disable tool tool-a" }))
|
||||
expect(onFilterChange).toHaveBeenCalledWith("test-server", { allowedTools: ["tool-b"] })
|
||||
})
|
||||
|
||||
it("adds tool to allowlist when checked", function () {
|
||||
var { onFilterChange } = renderRow({ filter: { allowedTools: ["tool-a"] } })
|
||||
fireEvent.click(screen.getByTestId("mcp-server-header-test-server"))
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Enable tool tool-b" }))
|
||||
expect(onFilterChange).toHaveBeenCalledWith("test-server", {
|
||||
allowedTools: ["tool-a", "tool-b"],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool checkboxes in blocklist mode", function () {
|
||||
it("adds tool to disabledTools when unchecked", function () {
|
||||
var { onFilterChange } = renderRow({ filter: { disabledTools: [] } })
|
||||
fireEvent.click(screen.getByTestId("mcp-server-header-test-server"))
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Disable tool tool-a" }))
|
||||
expect(onFilterChange).toHaveBeenCalledWith("test-server", { disabledTools: ["tool-a"] })
|
||||
})
|
||||
|
||||
it("removes tool from disabledTools when re-checked", function () {
|
||||
var { onFilterChange } = renderRow({ filter: { disabledTools: ["tool-b"] } })
|
||||
fireEvent.click(screen.getByTestId("mcp-server-header-test-server"))
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Enable tool tool-b" }))
|
||||
expect(onFilterChange).toHaveBeenCalledWith("test-server", { disabledTools: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool descriptions", function () {
|
||||
it("shows tool description when available", function () {
|
||||
renderRow({ filter: { allowedTools: ["tool-a"] } })
|
||||
fireEvent.click(screen.getByTestId("mcp-server-header-test-server"))
|
||||
expect(screen.getByText("First tool")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("does not render description span when not present", function () {
|
||||
renderRow({ filter: { disabledTools: [] } })
|
||||
fireEvent.click(screen.getByTestId("mcp-server-header-test-server"))
|
||||
var toolC = screen.getByTestId("mcp-tool-filter-tool-c")
|
||||
expect(toolC.querySelectorAll("span").length).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue