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:
Stefan Vetter 2026-04-07 09:21:23 +02:00
parent 79de65a8f5
commit 08c5012131
5 changed files with 875 additions and 35 deletions

View file

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

View 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>
)
}

View 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

View file

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

View file

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