feat: per-mode MCP filtering enhancements v3.52.0

- SE-1: deny-by-default guard for unavailable custom mode config

- SE-2: groupOptionsCache mode-scoped keys to prevent cross-mode contamination

- SE-3: recordToolUsage moved after MCP filter check

- Loading spinner for MCP servers/tools list while data loads
This commit is contained in:
Stefan Vetter 2026-04-07 22:18:31 +02:00
parent 08c5012131
commit 8fbd3ac20e
20 changed files with 604 additions and 145 deletions

View file

@ -1,36 +1,4 @@
customModes:
- slug: translate
name: 🌐 Translate
roleDefinition: You are Roo, a linguistic specialist focused on translating and managing localization files. Your responsibility is to help maintain and update translation files for the application, ensuring consistency and accuracy across all language resources.
whenToUse: Translate and manage localization files.
description: Translate and manage localization files.
groups:
- read
- command
- - edit
- fileRegex: (.*\.(md|ts|tsx|js|jsx)$|.*\.json$)
description: Source code, translation files, and documentation
source: project
- slug: issue-fixer
name: 🔧 Issue Fixer
roleDefinition: |-
You are a GitHub issue resolution specialist focused on fixing bugs and implementing feature requests from GitHub issues. Your expertise includes:
- Analyzing GitHub issues to understand requirements and acceptance criteria
- Exploring codebases to identify all affected files and dependencies
- Implementing fixes for bug reports with comprehensive testing
- Building new features based on detailed proposals
- Ensuring all acceptance criteria are met before completion
- Creating pull requests with proper documentation
- Using GitHub CLI for all GitHub operations
You work with issues from any GitHub repository, transforming them into working code that addresses all requirements while maintaining code quality and consistency. You use the GitHub CLI (gh) for all GitHub operations instead of MCP tools.
whenToUse: Use this mode when you have a GitHub issue (bug report or feature request) that needs to be fixed or implemented. Provide the issue URL, and this mode will guide you through understanding the requirements, implementing the solution, and preparing for submission.
description: Fix GitHub issues and implement features.
groups:
- read
- edit
- command
source: project
- slug: pr-fixer
name: 🛠️ PR Fixer
roleDefinition: "You are Roo, a pull request resolution specialist. Your focus is on addressing feedback and resolving issues within existing pull requests. Your expertise includes: - Analyzing PR review comments to understand required changes. - Checking CI/CD workflow statuses to identify failing tests. - Fetching and analyzing test logs to diagnose failures. - Identifying and resolving merge conflicts. - Guiding the user through the resolution process."
@ -146,3 +114,37 @@ customModes:
- command
- mcp
source: project
- slug: issue-fixer
name: 🔧 Issue Fixer
roleDefinition: |-
You are a GitHub issue resolution specialist focused on fixing bugs and implementing feature requests from GitHub issues. Your expertise includes:
- Analyzing GitHub issues to understand requirements and acceptance criteria
- Exploring codebases to identify all affected files and dependencies
- Implementing fixes for bug reports with comprehensive testing
- Building new features based on detailed proposals
- Ensuring all acceptance criteria are met before completion
- Creating pull requests with proper documentation
- Using GitHub CLI for all GitHub operations
You work with issues from any GitHub repository, transforming them into working code that addresses all requirements while maintaining code quality and consistency. You use the GitHub CLI (gh) for all GitHub operations instead of MCP tools.
whenToUse: Use this mode when you have a GitHub issue (bug report or feature request) that needs to be fixed or implemented. Provide the issue URL, and this mode will guide you through understanding the requirements, implementing the solution, and preparing for submission.
description: Fix GitHub issues and implement features.
groups:
- read
- edit
- command
- mcp
source: project
- slug: translate
name: 🌐 Translate
roleDefinition: You are Roo, a linguistic specialist focused on translating and managing localization files. Your responsibility is to help maintain and update translation files for the application, ensuring consistency and accuracy across all language resources.
whenToUse: Translate and manage localization files.
description: Translate and manage localization files.
groups:
- read
- command
- - edit
- fileRegex: (.*\.(md|ts|tsx|js|jsx)$|.*\.json$)
description: Source code, translation files, and documentation
- mcp
source: project

View file

@ -0,0 +1,26 @@
/**
* SE-3: recordToolUsage ordering — verification note.
*
* recordToolUsage ordering is verified by code review.
* The break at the filter rejection exits the case block before
* reaching the recording calls. See presentAssistantMessage.ts.
*
* A full integration test would require mocking the entire
* presentAssistantMessage pipeline (~200+ lines of setup including
* Task mock, MCP filter mock, provider state, and tool validation),
* which exceeds the practical threshold for a focused unit test.
*
* The guarantee that rejected MCP tools skip recordToolUsage is
* structurally enforced: the rejection path calls pushToolResult
* and breaks out of the case block, so the recording statements
* that follow are never reached.
*/
describe("SE-3: recordToolUsage ordering", () => {
it("is structurally enforced by code review (see comment above)", () => {
// This placeholder satisfies Vitest's requirement for at
// least one test in a .spec file. The actual guarantee is
// structural — see the JSDoc block above.
expect(true).toBe(true)
})
})

View file

@ -40,7 +40,7 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool"
import { formatResponse } from "../prompts/responses"
import { sanitizeToolUseId } from "../../utils/tool-id"
import { isMcpToolAllowedForMode } from "../../utils/mcp-filter"
import { isMcpToolAllowedForMode, isCustomModeWithoutConfig } from "../../utils/mcp-filter"
import type { ModeConfig } from "@roo-code/types"
/**
@ -252,11 +252,6 @@ export async function presentAssistantMessage(cline: Task) {
pushToolResult(formatResponse.toolError(errorString))
}
if (!mcpBlock.partial) {
cline.recordToolUsage("use_mcp_tool") // Record as use_mcp_tool for analytics
TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool")
}
// Resolve sanitized server name back to original server name
// The serverName from parsing is sanitized (e.g., "my_server" from "my server")
// We need the original name to find the actual MCP connection
@ -272,6 +267,17 @@ export async function presentAssistantMessage(cline: Task) {
// Step 5b: MCP tool filtering using frozen task mode
if (!mcpBlock.partial) {
const taskCustomModes = await cline.providerRef.deref()?.customModesManager.getCustomModes()
// ISSUE-15: deny-by-default when custom mode config is unavailable
if (isCustomModeWithoutConfig(cline.taskMode, taskCustomModes)) {
const errorMsg =
'MCP tool denied: custom mode "' +
cline.taskMode +
'" config is unavailable. Denying all MCP access.'
await cline.say("error", errorMsg)
pushToolResult(formatResponse.toolError(errorMsg))
cline.didRejectTool = true
break
}
// FLAG-E: getCustomModes() uses a 10-second TTL cache, no disk I/O on each call
if (!shouldAllowMcpToolUse(resolvedServerName, mcpBlock.toolName, cline.taskMode, taskCustomModes)) {
const errorMsg =
@ -289,6 +295,12 @@ export async function presentAssistantMessage(cline: Task) {
}
}
// Record usage AFTER filter check so rejected tools are not counted (FLAG-1)
if (!mcpBlock.partial) {
cline.recordToolUsage("use_mcp_tool")
TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool")
}
// Execute the MCP tool using the same handler as use_mcp_tool
// Create a synthetic ToolUse block that the useMcpToolTool can handle
const syntheticToolUse: ToolUse<"use_mcp_tool"> = {

View file

@ -5,7 +5,7 @@ import { formatResponse } from "../prompts/responses"
import { t } from "../../i18n"
import type { ToolUse } from "../../shared/tools"
import { toolNamesMatch } from "../../utils/mcp-name"
import { isMcpServerAllowedForMode, isMcpToolAllowedForMode } from "../../utils/mcp-filter"
import { isMcpServerAllowedForMode, isMcpToolAllowedForMode, isCustomModeWithoutConfig } from "../../utils/mcp-filter"
import { BaseTool, ToolCallbacks } from "./BaseTool"
@ -42,6 +42,15 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
// Defense-in-depth: check MCP server/tool filtering for the current mode.
// FLAG-E: 10-second TTL cache, no disk I/O per call
const customModes = await task.providerRef.deref()?.customModesManager?.getCustomModes()
// ISSUE-15: deny-by-default when custom mode config is unavailable
if (isCustomModeWithoutConfig(task.taskMode, customModes)) {
task.consecutiveMistakeCount++
task.recordToolError("use_mcp_tool")
const msg = 'MCP denied: custom mode "' + task.taskMode + '" config is unavailable'
await task.say("error", msg)
pushToolResult(formatResponse.toolError(msg))
return
}
if (!isMcpServerAllowedForMode(serverName, task.taskMode, customModes)) {
task.consecutiveMistakeCount++
task.recordToolError("use_mcp_tool")

View file

@ -7,6 +7,7 @@ import { Task } from "../../task/Task"
vi.mock("../../../utils/mcp-filter", () => ({
isMcpServerAllowedForMode: vi.fn().mockReturnValue(true),
isMcpToolAllowedForMode: vi.fn().mockReturnValue(true),
isCustomModeWithoutConfig: vi.fn().mockReturnValue(false),
}))
import { isMcpServerAllowedForMode } from "../../../utils/mcp-filter"

View file

@ -7,6 +7,7 @@ import { Task } from "../../task/Task"
vi.mock("../../../utils/mcp-filter", () => ({
isMcpServerAllowedForMode: vi.fn().mockReturnValue(true),
isMcpToolAllowedForMode: vi.fn().mockReturnValue(true),
isCustomModeWithoutConfig: vi.fn().mockReturnValue(false),
}))
import { isMcpServerAllowedForMode, isMcpToolAllowedForMode } from "../../../utils/mcp-filter"

View file

@ -4,6 +4,13 @@ import { useMcpToolTool } from "../UseMcpToolTool"
import { Task } from "../../task/Task"
import { ToolUse } from "../../../shared/tools"
// Mock mcp-filter to bypass SE-1 deny-by-default guards
vi.mock("../../../utils/mcp-filter", () => ({
isMcpServerAllowedForMode: vi.fn().mockReturnValue(true),
isMcpToolAllowedForMode: vi.fn().mockReturnValue(true),
isCustomModeWithoutConfig: vi.fn().mockReturnValue(false),
}))
// Mock dependencies
vi.mock("../../prompts/responses", () => ({
formatResponse: {

View file

@ -3,7 +3,7 @@ import type { ClineAskUseMcpServer } from "@roo-code/types"
import type { ToolUse } from "../../shared/tools"
import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { isMcpServerAllowedForMode } from "../../utils/mcp-filter"
import { isMcpServerAllowedForMode, isCustomModeWithoutConfig } from "../../utils/mcp-filter"
import { BaseTool, ToolCallbacks } from "./BaseTool"
@ -37,6 +37,15 @@ export class AccessMcpResourceTool extends BaseTool<"access_mcp_resource"> {
// Defense-in-depth: check MCP server filtering for the current mode.
// FLAG-E: 10-second TTL cache, no disk I/O per call
const customModes = await task.providerRef.deref()?.customModesManager?.getCustomModes()
// ISSUE-15: deny-by-default when custom mode config is unavailable
if (isCustomModeWithoutConfig(task.taskMode, customModes)) {
task.consecutiveMistakeCount++
task.recordToolError("access_mcp_resource")
const msg = 'MCP denied: custom mode "' + task.taskMode + '" config is unavailable'
await task.say("error", msg)
pushToolResult(formatResponse.toolError(msg))
return
}
if (!isMcpServerAllowedForMode(server_name, task.taskMode, customModes)) {
task.consecutiveMistakeCount++
task.recordToolError("access_mcp_resource")

View file

@ -0,0 +1,63 @@
/**
* SE-1: Tests for isCustomModeWithoutConfig.
*
* This function returns true when the mode slug does NOT match any
* built-in mode AND is NOT found in the customModes array — i.e. the
* mode is a custom mode whose configuration is unavailable.
*/
import type { ModeConfig } from "@roo-code/types"
import { isCustomModeWithoutConfig } from "../mcp-filter"
// ---------------------------------------------------------------------------
// Helpers — minimal ModeConfig objects for testing.
// roleDefinition is required by the schema but the function under test only
// inspects slug matching, so we cast to ModeConfig for type-safety.
// ---------------------------------------------------------------------------
function minimalMode(slug: string, name: string, groups: string[]): ModeConfig {
return {
slug,
name,
roleDefinition: "test role",
groups,
} as ModeConfig
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("isCustomModeWithoutConfig", () => {
it('returns false for built-in mode "code" with no custom modes', () => {
expect(isCustomModeWithoutConfig("code", undefined)).toBe(false)
})
it('returns false for built-in mode "architect" with no custom modes', () => {
expect(isCustomModeWithoutConfig("architect", undefined)).toBe(false)
})
it("returns true for unknown custom mode with no custom modes", () => {
expect(isCustomModeWithoutConfig("my-custom", undefined)).toBe(true)
})
it("returns true for unknown custom mode with empty custom modes array", () => {
expect(isCustomModeWithoutConfig("my-custom", [])).toBe(true)
})
it("returns false for custom mode when config is available", () => {
const modes = [minimalMode("my-custom", "My Custom", ["read", "edit"])]
expect(isCustomModeWithoutConfig("my-custom", modes)).toBe(false)
})
it("returns true for custom mode when only different modes in array", () => {
const modes = [minimalMode("other-mode", "Other", ["read"])]
expect(isCustomModeWithoutConfig("my-custom", modes)).toBe(true)
})
it("returns false for built-in mode even with custom modes array", () => {
const modes = [minimalMode("my-custom", "My Custom", ["read"])]
expect(isCustomModeWithoutConfig("code", modes)).toBe(false)
})
})

View file

@ -52,6 +52,20 @@ function findMode(modeSlug: string, customModes?: ModeConfig[]): ModeConfig | un
return DEFAULT_MODES.find((m) => m.slug === modeSlug)
}
/**
* Returns true when a mode slug refers to a custom mode whose config
* cannot be resolved. Built-in modes always return false because they
* are in DEFAULT_MODES. This is the deny-by-default guard for ISSUE-15.
*/
export function isCustomModeWithoutConfig(modeSlug: string, customModes?: ModeConfig[]): boolean {
const mode = findMode(modeSlug, customModes)
if (mode) {
return false
}
// Mode not found — check if it is a built-in mode
return !DEFAULT_MODES.some((m) => m.slug === modeSlug)
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

View file

@ -29,23 +29,23 @@ describe("groupOptionsCache", () => {
describe("removeGroupWithCache — caching tuple options", () => {
it("caches options when removing a group with tuple entry", () => {
const cache = new Map<string, object>()
const result = removeGroupWithCache(cache, groupsWithMcpTuple, "mcp")
const result = removeGroupWithCache(cache, groupsWithMcpTuple, "mcp", "test-mode")
// The mcp group should be removed
expect(result).toEqual([readGroup, editGroup])
// The cache should contain the mcp options
expect(cache.get("mcp")).toEqual(mcpOptions)
// The cache should contain the mcp options under mode-scoped key
expect(cache.get("test-mode:mcp")).toEqual(mcpOptions)
})
it("does not cache anything for a plain string group", () => {
const cache = new Map<string, object>()
const result = removeGroupWithCache(cache, groupsPlainOnly, "mcp")
const result = removeGroupWithCache(cache, groupsPlainOnly, "mcp", "test-mode")
expect(result).toEqual([readGroup, editGroup])
// Cache should NOT have an entry for mcp
expect(cache.has("mcp")).toBe(false)
expect(cache.has("test-mode:mcp")).toBe(false)
})
})
@ -54,10 +54,10 @@ describe("groupOptionsCache", () => {
const cache = new Map<string, object>()
// First remove to populate cache
const afterRemove = removeGroupWithCache(cache, groupsWithMcpTuple, "mcp")
const afterRemove = removeGroupWithCache(cache, groupsWithMcpTuple, "mcp", "test-mode")
// Now re-add
const afterAdd = addGroupWithCache(cache, afterRemove, "mcp")
const afterAdd = addGroupWithCache(cache, afterRemove, "mcp", "test-mode")
// Should restore as tuple with cached options
const mcpEntry = afterAdd.find((g) => (Array.isArray(g) ? g[0] === "mcp" : g === "mcp"))
@ -69,10 +69,10 @@ describe("groupOptionsCache", () => {
const cache = new Map<string, object>()
// Remove plain 'mcp' — nothing to cache
const afterRemove = removeGroupWithCache(cache, groupsPlainOnly, "mcp")
const afterRemove = removeGroupWithCache(cache, groupsPlainOnly, "mcp", "test-mode")
// Re-add — should be plain string since no cache
const afterAdd = addGroupWithCache(cache, afterRemove, "mcp")
const afterAdd = addGroupWithCache(cache, afterRemove, "mcp", "test-mode")
const mcpEntry = afterAdd.find((g) => (Array.isArray(g) ? g[0] === "mcp" : g === "mcp"))
expect(mcpEntry).toBe("mcp")
@ -83,17 +83,17 @@ describe("groupOptionsCache", () => {
it("populates cache from groups containing tuples", () => {
const cache = new Map<string, object>()
syncCacheFromGroups(cache, groupsWithMcpTuple)
syncCacheFromGroups(cache, groupsWithMcpTuple, "test-mode")
expect(cache.get("mcp")).toEqual(mcpOptions)
expect(cache.get("test-mode:mcp")).toEqual(mcpOptions)
})
it("does not populate cache from plain string groups", () => {
const cache = new Map<string, object>()
syncCacheFromGroups(cache, groupsPlainOnly)
syncCacheFromGroups(cache, groupsPlainOnly, "test-mode")
expect(cache.has("mcp")).toBe(false)
expect(cache.has("test-mode:mcp")).toBe(false)
})
it("updates cache when called with new tuple data", () => {
@ -107,12 +107,12 @@ describe("groupOptionsCache", () => {
const updatedTuple: GroupEntry = ["mcp", updatedOptions]
// First sync with original data
syncCacheFromGroups(cache, groupsWithMcpTuple)
expect(cache.get("mcp")).toEqual(mcpOptions)
syncCacheFromGroups(cache, groupsWithMcpTuple, "test-mode")
expect(cache.get("test-mode:mcp")).toEqual(mcpOptions)
// Sync again with updated data
syncCacheFromGroups(cache, [readGroup, editGroup, updatedTuple])
expect(cache.get("mcp")).toEqual(updatedOptions)
syncCacheFromGroups(cache, [readGroup, editGroup, updatedTuple], "test-mode")
expect(cache.get("test-mode:mcp")).toEqual(updatedOptions)
})
})
@ -121,16 +121,16 @@ describe("groupOptionsCache", () => {
const cache = new Map<string, object>()
// Sync cache from initial state (simulates useEffect)
syncCacheFromGroups(cache, groupsWithMcpTuple)
syncCacheFromGroups(cache, groupsWithMcpTuple, "test-mode")
// Toggle off (uncheck)
const afterUncheck = removeGroupWithCache(cache, groupsWithMcpTuple, "mcp")
const afterUncheck = removeGroupWithCache(cache, groupsWithMcpTuple, "mcp", "test-mode")
// Verify mcp is removed
expect(afterUncheck.some((g) => (Array.isArray(g) ? g[0] === "mcp" : g === "mcp"))).toBe(false)
// Toggle on (re-check)
const afterRecheck = addGroupWithCache(cache, afterUncheck, "mcp")
const afterRecheck = addGroupWithCache(cache, afterUncheck, "mcp", "test-mode")
// Verify mcp is restored with full config
const restored = afterRecheck.find((g) => (Array.isArray(g) ? g[0] === "mcp" : g === "mcp"))

View file

@ -10,6 +10,7 @@ export interface McpFilterConfigProps {
mcpGroupOptions: McpGroupOptions | undefined
onOptionsChange: (options: McpGroupOptions | undefined) => void
isEditing: boolean
isLoading?: boolean
}
function getDefaultPolicy(options: McpGroupOptions | undefined): "allow" | "deny" {
@ -43,11 +44,11 @@ function buildCleanOptions(
policy: "allow" | "deny",
servers: Record<string, McpServerFilter> | undefined,
): McpGroupOptions | undefined {
var hasServers = servers && Object.keys(servers).length > 0
const hasServers = servers && Object.keys(servers).length > 0
if (policy === "allow" && !hasServers) {
return undefined
}
var result: McpGroupOptions = {}
const result: McpGroupOptions = {}
if (policy !== "allow") {
result.mcpDefaultPolicy = policy
}
@ -57,24 +58,30 @@ function buildCleanOptions(
return result
}
export function McpFilterConfig({ mcpServers, mcpGroupOptions, onOptionsChange, isEditing }: McpFilterConfigProps) {
var policy = getDefaultPolicy(mcpGroupOptions)
var serverCount = mcpServers.length
export function McpFilterConfig({
mcpServers,
mcpGroupOptions,
onOptionsChange,
isEditing,
isLoading,
}: McpFilterConfigProps) {
const policy = getDefaultPolicy(mcpGroupOptions)
const serverCount = mcpServers.length
var handlePolicyChange = useCallback(
const handlePolicyChange = useCallback(
function (newPolicy: string) {
var typedPolicy = newPolicy as "allow" | "deny"
var currentServers = mcpGroupOptions?.mcpServers
var updated = buildCleanOptions(typedPolicy, currentServers)
const typedPolicy = newPolicy as "allow" | "deny"
const currentServers = mcpGroupOptions?.mcpServers
const updated = buildCleanOptions(typedPolicy, currentServers)
onOptionsChange(updated)
},
[mcpGroupOptions, onOptionsChange],
)
var handleServerFilterChange = useCallback(
const handleServerFilterChange = useCallback(
function (serverName: string, filter: McpServerFilter | undefined) {
var currentServers = mcpGroupOptions?.mcpServers || {}
var updatedServers: Record<string, McpServerFilter>
const currentServers = mcpGroupOptions?.mcpServers || {}
let updatedServers: Record<string, McpServerFilter>
if (filter) {
updatedServers = { ...currentServers, [serverName]: filter }
@ -83,8 +90,8 @@ export function McpFilterConfig({ mcpServers, mcpGroupOptions, onOptionsChange,
delete updatedServers[serverName]
}
var currentPolicy = getDefaultPolicy(mcpGroupOptions)
var updated = buildCleanOptions(currentPolicy, updatedServers)
const currentPolicy = getDefaultPolicy(mcpGroupOptions)
const updated = buildCleanOptions(currentPolicy, updatedServers)
onOptionsChange(updated)
},
[mcpGroupOptions, onOptionsChange],
@ -92,6 +99,16 @@ export function McpFilterConfig({ mcpServers, mcpGroupOptions, onOptionsChange,
// Read-only mode
if (!isEditing) {
if (isLoading && serverCount === 0) {
return (
<div
className="flex items-center gap-2 py-2 ml-5 text-xs text-vscode-descriptionForeground"
data-testid="mcp-loading-spinner">
<span className="codicon codicon-loading codicon-modifier-spin" />
<span>Loading MCP servers...</span>
</div>
)
}
return (
<div
data-testid="mcp-filter-config-readonly"
@ -137,14 +154,23 @@ export function McpFilterConfig({ mcpServers, mcpGroupOptions, onOptionsChange,
</span>
</div>
{serverCount === 0 ? (
{isLoading && serverCount === 0 && (
<div
className="flex items-center gap-2 py-2 text-xs text-vscode-descriptionForeground"
data-testid="mcp-loading-spinner">
<span className="codicon codicon-loading codicon-modifier-spin" />
<span>Loading MCP servers...</span>
</div>
)}
{!isLoading && serverCount === 0 && (
<div className="text-xs text-vscode-descriptionForeground italic py-2">
No MCP servers connected
</div>
) : (
)}
{serverCount > 0 && (
<div className="space-y-1">
{mcpServers.map(function (server) {
var tools = (server.tools || []).map(function (t) {
const tools = (server.tools || []).map(function (t) {
return { name: t.name, description: t.description }
})
return (

View file

@ -86,7 +86,7 @@ export function McpServerFilterRow({
function () {
if (isDisabled) {
// Re-enable: remove disabled flag, keep other filter settings
var updated: McpServerFilter | undefined = filter ? { ...filter, disabled: undefined } : undefined
let updated: McpServerFilter | undefined = filter ? { ...filter, disabled: undefined } : undefined
// Clean up empty object
if (updated && !updated.allowedTools && !updated.disabledTools && !updated.disabled) {
updated = undefined
@ -94,14 +94,14 @@ export function McpServerFilterRow({
onFilterChange(serverName, updated)
} else {
// Disable the server
var newFilter: McpServerFilter = filter ? { ...filter, disabled: true } : { disabled: true }
const newFilter: McpServerFilter = filter ? { ...filter, disabled: true } : { disabled: true }
onFilterChange(serverName, newFilter)
}
},
[isDisabled, filter, serverName, onFilterChange],
)
var handleToggleExpand = useCallback(
const handleToggleExpand = useCallback(
function () {
if (!isDisabled) {
setIsExpanded(function (prev) {
@ -112,17 +112,17 @@ export function McpServerFilterRow({
[isDisabled],
)
var handleFilterModeChange = useCallback(
const handleFilterModeChange = useCallback(
function (newMode: FilterMode) {
if (newMode === "allowAll") {
var cleaned: McpServerFilter | undefined = filter ? { disabled: filter.disabled } : undefined
let 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) {
const allNames = availableTools.map(function (t) {
return t.name
})
onFilterChange(serverName, {
@ -142,13 +142,13 @@ export function McpServerFilterRow({
[filter, serverName, availableTools, onFilterChange],
)
var handleToggleTool = useCallback(
const handleToggleTool = useCallback(
function (toolName: string) {
var currentlyEnabled = isToolEnabled(toolName, filter)
const currentlyEnabled = isToolEnabled(toolName, filter)
if (filterMode === "allowlist") {
var currentAllowed = filter?.allowedTools || []
var newAllowed = currentlyEnabled
const currentAllowed = filter?.allowedTools || []
const newAllowed = currentlyEnabled
? currentAllowed.filter(function (n) {
return n !== toolName
})
@ -158,8 +158,8 @@ export function McpServerFilterRow({
allowedTools: newAllowed,
})
} else if (filterMode === "blocklist") {
var currentDisabled = filter?.disabledTools || []
var newDisabled = currentlyEnabled
const currentDisabled = filter?.disabledTools || []
const newDisabled = currentlyEnabled
? currentDisabled.concat([toolName])
: currentDisabled.filter(function (n) {
return n !== toolName
@ -271,7 +271,7 @@ export function McpServerFilterRow({
{filterMode !== "allowAll" && availableTools.length > 0 && (
<div className="flex flex-col gap-1">
{availableTools.map(function (tool) {
var enabled = isToolEnabled(tool.name, filter)
const enabled = isToolEnabled(tool.name, filter)
return (
<div
key={tool.name}

View file

@ -68,8 +68,8 @@ function getGroupName(group: GroupEntry): ToolGroup {
// 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]
for (let i = 0; i < groups.length; i++) {
const entry = groups[i]
if (Array.isArray(entry) && entry[0] === "mcp" && entry[1]) {
return entry[1] as McpGroupOptions
}
@ -80,7 +80,7 @@ function getMcpOptionsFromGroups(groups: GroupEntry[]): McpGroupOptions | undefi
// 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]
const name = typeof entry === "string" ? entry : entry[0]
if (name === "mcp") {
return options ? (["mcp", options] as GroupEntry) : "mcp"
}
@ -100,6 +100,7 @@ const ModesView = () => {
setCustomInstructions,
customModes,
mcpServers,
mcpServersLoaded,
} = useExtensionState()
// Use a local state to track the visually active mode
@ -496,7 +497,7 @@ const ModesView = () => {
// when a user toggles a group off and back on.
useEffect(() => {
for (const cm of customModes || []) {
syncCacheFromGroups(groupOptionsCache.current, cm.groups || [])
syncCacheFromGroups(groupOptionsCache.current, cm.groups || [], cm.slug)
}
}, [customModes])
@ -507,22 +508,21 @@ const ModesView = () => {
if (!isCustomMode) return // Prevent changes to built-in modes
const target = (e as CustomEvent)?.detail?.target || (e.target as HTMLInputElement)
const checked = target.checked
const oldGroups = customMode?.groups || []
if (!customMode) return
const oldGroups = customMode.groups || []
let newGroups: GroupEntry[]
if (checked) {
newGroups = addGroupWithCache(groupOptionsCache.current, oldGroups, group)
newGroups = addGroupWithCache(groupOptionsCache.current, oldGroups, group, customMode.slug)
} else {
newGroups = removeGroupWithCache(groupOptionsCache.current, oldGroups, group)
newGroups = removeGroupWithCache(groupOptionsCache.current, oldGroups, group, customMode.slug)
}
if (customMode) {
const source = customMode.source || "global"
const source = customMode.source || "global"
updateCustomMode(customMode.slug, {
...customMode,
groups: newGroups,
source,
})
}
updateCustomMode(customMode.slug, {
...customMode,
groups: newGroups,
source,
})
},
[updateCustomMode],
)
@ -1212,15 +1212,19 @@ const ModesView = () => {
return (
<McpFilterConfig
mcpServers={mcpServers || []}
isLoading={!mcpServersLoaded}
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)
groupOptionsCache.current.set(
customMode.slug + ":" + "mcp",
options,
)
} else {
groupOptionsCache.current.delete("mcp")
groupOptionsCache.current.delete(customMode.slug + ":" + "mcp")
}
updateCustomMode(customMode.slug, {
...customMode,
@ -1265,6 +1269,7 @@ const ModesView = () => {
return (
<McpFilterConfig
mcpServers={mcpServers || []}
isLoading={!mcpServersLoaded}
mcpGroupOptions={mcpOptions}
onOptionsChange={() => {}}
isEditing={false}

View file

@ -0,0 +1,189 @@
// npx vitest src/components/modes/__tests__/McpFilterConfig-loading.spec.tsx
import React from "react"
import { render, screen } from "@/utils/test-utils"
import { McpFilterConfig } from "../McpFilterConfig"
import type { McpFilterConfigProps } from "../McpFilterConfig"
import type { McpServer } from "@roo-code/types"
/**
* Mock Select UI sub-module to avoid Radix portal issues in tests.
* Only mocking the select sub-path to preserve other barrel exports
* (e.g. TooltipProvider used by the test-utils wrapper).
*/
vi.mock("@/components/ui/select", function () {
return {
Select: function MockSelect({ children, value }: any) {
return (
<div data-testid="mock-select" data-value={value}>
{children}
</div>
)
},
SelectTrigger: function MockSelectTrigger({ children }: any) {
return <button data-testid="mock-select-trigger">{children}</button>
},
SelectContent: function MockSelectContent({ children }: any) {
return <div data-testid="mock-select-content">{children}</div>
},
SelectItem: function MockSelectItem({ children, value }: any) {
return <div data-value={value}>{children}</div>
},
SelectValue: function MockSelectValue() {
return <span>mock-value</span>
},
}
})
/**
* Mock McpServerFilterRow to avoid testing child component internals.
*/
vi.mock("../McpServerFilterRow", function () {
return {
McpServerFilterRow: function MockRow({ serverName }: any) {
return <div data-testid={"mcp-server-filter-row-" + serverName}>{serverName}</div>
},
}
})
/**
* Creates a minimal McpServer mock with required fields.
*/
function createMockServer(name: string, status?: "connected" | "connecting" | "disconnected"): McpServer {
return {
name: name,
config: "{}",
status: status || "connected",
tools: [{ name: "tool-1", description: "A test tool", inputSchema: undefined }],
} as McpServer
}
/**
* Helper to render McpFilterConfig with sensible defaults.
* Allows overriding any prop via partial overrides.
*/
function renderConfig(overrides: Partial<McpFilterConfigProps> = {}) {
const defaultProps: McpFilterConfigProps = {
mcpServers: [],
mcpGroupOptions: undefined,
onOptionsChange: vi.fn(),
isEditing: true,
...overrides,
}
return render(<McpFilterConfig {...defaultProps} />)
}
describe("McpFilterConfig loading state", function () {
beforeEach(function () {
vi.clearAllMocks()
})
/**
* When isLoading is true and no servers have been fetched yet,
* the component should display a loading spinner instead of
* the empty "No MCP servers connected" message.
*/
it("shows loading spinner when isLoading is true and no servers", function () {
renderConfig({
isLoading: true,
mcpServers: [],
isEditing: true,
} as any)
// Verify spinner is present
const spinner = screen.getByTestId("mcp-loading-spinner")
expect(spinner).toBeInTheDocument()
// Verify loading text is shown
expect(screen.getByText("Loading MCP servers...")).toBeInTheDocument()
// Verify empty state message is NOT shown
expect(screen.queryByText("No MCP servers connected")).not.toBeInTheDocument()
})
/**
* When isLoading is false and no servers exist, the component
* should show the standard empty state message.
*/
it("shows empty message when isLoading is false and no servers", function () {
renderConfig({
isLoading: false,
mcpServers: [],
isEditing: true,
} as any)
// Verify empty message is present
expect(screen.getByText("No MCP servers connected")).toBeInTheDocument()
// Verify spinner is NOT present
expect(screen.queryByTestId("mcp-loading-spinner")).not.toBeInTheDocument()
})
/**
* When servers have loaded successfully, the component should
* render the server list without any spinner or empty message.
*/
it("shows server list when isLoading is false and servers exist", function () {
const servers = [createMockServer("my-mcp-server"), createMockServer("another-server")]
renderConfig({
isLoading: false,
mcpServers: servers,
isEditing: true,
} as any)
// Verify spinner is NOT present
expect(screen.queryByTestId("mcp-loading-spinner")).not.toBeInTheDocument()
// Verify empty message is NOT present
expect(screen.queryByText("No MCP servers connected")).not.toBeInTheDocument()
// Verify server rows are rendered
expect(screen.getByTestId("mcp-server-filter-row-my-mcp-server")).toBeInTheDocument()
expect(screen.getByTestId("mcp-server-filter-row-another-server")).toBeInTheDocument()
})
/**
* When isLoading is still true but servers have already been provided,
* the server list should take priority over the loading spinner.
* This handles the case where data arrives before the loading flag resets.
*/
it("does not show spinner when isLoading is true but servers already loaded", function () {
const servers = [createMockServer("existing-server")]
renderConfig({
isLoading: true,
mcpServers: servers,
isEditing: true,
} as any)
// Spinner should NOT be present because servers override loading
expect(screen.queryByTestId("mcp-loading-spinner")).not.toBeInTheDocument()
// Loading text should NOT be present
expect(screen.queryByText("Loading MCP servers...")).not.toBeInTheDocument()
// Server content should be present
expect(screen.getByTestId("mcp-server-filter-row-existing-server")).toBeInTheDocument()
})
/**
* The loading spinner should also appear in read-only mode
* (isEditing=false) when data hasn't loaded yet.
*/
it("shows spinner in read-only mode when loading", function () {
renderConfig({
isLoading: true,
mcpServers: [],
isEditing: false,
} as any)
// Verify spinner is present even in read-only mode
const spinner = screen.getByTestId("mcp-loading-spinner")
expect(spinner).toBeInTheDocument()
// Verify loading text is shown
expect(screen.getByText("Loading MCP servers...")).toBeInTheDocument()
})
})

View file

@ -46,14 +46,14 @@ vi.mock("@/components/ui/toggle-switch", function () {
}
})
var mockTools = [
const mockTools = [
{ name: "tool-a", description: "First tool" },
{ name: "tool-b", description: "Second tool" },
{ name: "tool-c" },
]
function renderRow(overrides: Partial<McpServerFilterRowProps> = {}) {
var defaultProps: McpServerFilterRowProps = {
const defaultProps: McpServerFilterRowProps = {
serverName: "test-server",
serverStatus: "connected",
availableTools: mockTools,
@ -108,48 +108,48 @@ describe("McpServerFilterRow", function () {
})
it("shows green dot when connected", function () {
var { container } = renderRow({ serverStatus: "connected" })
const { container } = renderRow({ serverStatus: "connected" })
expect(container.querySelector(".bg-vscode-charts-green")).toBeInTheDocument()
})
it("shows yellow dot when connecting", function () {
var { container } = renderRow({ serverStatus: "connecting" })
const { container } = renderRow({ serverStatus: "connecting" })
expect(container.querySelector(".bg-vscode-charts-yellow")).toBeInTheDocument()
})
it("shows gray dot when disconnected", function () {
var { container } = renderRow({ serverStatus: "disconnected" })
const { 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" })
const 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" })
const 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 })
const { 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 } })
const { 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({
const { onFilterChange } = renderRow({
filter: { disabled: true, allowedTools: ["tool-a"] },
})
fireEvent.click(screen.getByRole("switch", { name: "Toggle test-server server" }))
@ -174,14 +174,14 @@ describe("McpServerFilterRow", function () {
it("does not expand when server is disabled", function () {
renderRow({ filter: { disabled: true } })
var row = screen.getByTestId("mcp-server-filter-row-test-server")
const 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)
const result = renderRow(overrides)
fireEvent.click(screen.getByTestId("mcp-server-header-test-server"))
return result
}
@ -192,7 +192,7 @@ describe("McpServerFilterRow", function () {
})
it("switches to allowlist mode", function () {
var { onFilterChange } = expandRow()
const { onFilterChange } = expandRow()
fireEvent.click(screen.getByTestId("mcp-filter-mode-btn-allowlist"))
expect(onFilterChange).toHaveBeenCalledWith("test-server", {
allowedTools: ["tool-a", "tool-b", "tool-c"],
@ -201,7 +201,7 @@ describe("McpServerFilterRow", function () {
})
it("switches to blocklist mode", function () {
var { onFilterChange } = expandRow()
const { onFilterChange } = expandRow()
fireEvent.click(screen.getByTestId("mcp-filter-mode-btn-blocklist"))
expect(onFilterChange).toHaveBeenCalledWith("test-server", {
disabledTools: [],
@ -210,7 +210,7 @@ describe("McpServerFilterRow", function () {
})
it("switches back to allow all mode", function () {
var { onFilterChange } = expandRow({ filter: { allowedTools: ["tool-a"] } })
const { onFilterChange } = expandRow({ filter: { allowedTools: ["tool-a"] } })
fireEvent.click(screen.getByTestId("mcp-filter-mode-btn-allowAll"))
expect(onFilterChange).toHaveBeenCalledWith("test-server", undefined)
})
@ -226,14 +226,14 @@ describe("McpServerFilterRow", function () {
})
it("removes tool from allowlist when unchecked", function () {
var { onFilterChange } = renderRow({ filter: { allowedTools: ["tool-a", "tool-b"] } })
const { 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"] } })
const { 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", {
@ -244,14 +244,14 @@ describe("McpServerFilterRow", function () {
describe("tool checkboxes in blocklist mode", function () {
it("adds tool to disabledTools when unchecked", function () {
var { onFilterChange } = renderRow({ filter: { disabledTools: [] } })
const { 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"] } })
const { 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: [] })
@ -268,7 +268,7 @@ describe("McpServerFilterRow", function () {
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")
const toolC = screen.getByTestId("mcp-tool-filter-tool-c")
expect(toolC.querySelectorAll("span").length).toBe(1)
})
})

View file

@ -0,0 +1,75 @@
/**
* SE-2: Tests for groupOptionsCache with mode-scoped keys.
*
* The updated functions accept an additional `modeSlug` parameter and
* store / retrieve cache entries under composite keys of the form
* 'modeSlug:groupName' so that options from different modes never
* collide.
*/
import type { GroupEntry, ToolGroup } from "@roo-code/types"
import { syncCacheFromGroups, removeGroupWithCache, addGroupWithCache } from "../groupOptionsCache"
describe("groupOptionsCache mode-scoped keys", () => {
it("syncCacheFromGroups stores options under mode-scoped key", () => {
const cache = new Map<string, object>()
const groups: GroupEntry[] = [["mcp", { mcpServers: { s1: {} } }]]
syncCacheFromGroups(cache, groups, "modeA")
expect(cache.get("modeA:mcp")).toEqual({ mcpServers: { s1: {} } })
// Old un-scoped key must NOT be used
expect(cache.get("mcp")).toBeUndefined()
})
it("syncCacheFromGroups keeps separate entries for different modes", () => {
const cache = new Map<string, object>()
const groupsA: GroupEntry[] = [["mcp", { mcpDefaultPolicy: "deny" }]]
const groupsB: GroupEntry[] = [["mcp", { mcpServers: { x: { disabled: true } } }]]
syncCacheFromGroups(cache, groupsA, "modeA")
syncCacheFromGroups(cache, groupsB, "modeB")
expect(cache.get("modeA:mcp")).toEqual({ mcpDefaultPolicy: "deny" })
expect(cache.get("modeB:mcp")).toEqual({
mcpServers: { x: { disabled: true } },
})
})
it("removeGroupWithCache stores options under mode-scoped key", () => {
const cache = new Map<string, object>()
const groups: GroupEntry[] = [["mcp", { mcpServers: { s1: {} } }], "read"]
const result = removeGroupWithCache(cache, groups, "mcp", "modeA")
expect(result).toHaveLength(1)
expect(result[0]).toBe("read")
expect(cache.get("modeA:mcp")).toEqual({ mcpServers: { s1: {} } })
})
it("addGroupWithCache restores correct mode options", () => {
const cache = new Map<string, object>()
cache.set("modeA:mcp", { mcpDefaultPolicy: "deny" })
cache.set("modeB:mcp", { mcpServers: { x: {} } })
const groups: GroupEntry[] = ["read"]
const result = addGroupWithCache(cache, groups, "mcp" as ToolGroup, "modeA")
expect(result).toHaveLength(2)
expect(result[1]).toEqual(["mcp", { mcpDefaultPolicy: "deny" }])
})
it("addGroupWithCache returns plain string when no cache entry for mode", () => {
const cache = new Map<string, object>()
cache.set("modeA:mcp", { mcpDefaultPolicy: "deny" })
const groups: GroupEntry[] = ["read"]
// Request for modeB which has NO cached entry
const result = addGroupWithCache(cache, groups, "mcp" as ToolGroup, "modeB")
expect(result).toHaveLength(2)
expect(result[1]).toBe("mcp")
})
})

View file

@ -12,13 +12,21 @@ export function getGroupName(entry: GroupEntry): string {
}
/**
* Synchronise a cache map with the current groups array.
* For every tuple entry, upsert its options into the cache.
* Build a mode-scoped cache key: 'modeSlug:groupName'.
*/
export function syncCacheFromGroups(cache: Map<string, object>, groups: GroupEntry[]): void {
function cacheKey(modeSlug: string, groupName: string): string {
return modeSlug + ":" + groupName
}
/**
* Synchronise a cache map with the current groups array.
* For every tuple entry, upsert its options into the cache
* under a mode-scoped key.
*/
export function syncCacheFromGroups(cache: Map<string, object>, groups: GroupEntry[], modeSlug: string): void {
for (const entry of groups) {
if (Array.isArray(entry) && entry[1]) {
cache.set(entry[0], entry[1])
cache.set(cacheKey(modeSlug, entry[0]), entry[1])
}
}
}
@ -26,7 +34,7 @@ export function syncCacheFromGroups(cache: Map<string, object>, groups: GroupEnt
/**
* Remove a group by name. When the entry being removed is a
* tuple (i.e. it carries options), stash those options in the
* cache so they can be restored later.
* cache under a mode-scoped key so they can be restored later.
*
* Returns the filtered groups array.
*/
@ -34,18 +42,19 @@ export function removeGroupWithCache(
cache: Map<string, object>,
groups: GroupEntry[],
groupName: string,
modeSlug: string,
): GroupEntry[] {
const entry = groups.find((g) => getGroupName(g) === groupName)
if (entry && Array.isArray(entry) && entry[1]) {
cache.set(entry[0], entry[1])
cache.set(cacheKey(modeSlug, entry[0]), entry[1])
}
return groups.filter((g) => getGroupName(g) !== groupName)
}
/**
* Add a group by name. If the cache contains previously-saved
* options for this group, restore it as a tuple [name, options].
* Otherwise add it as a plain string.
* options for this group under the current mode, restore it as
* a tuple [name, options]. Otherwise add it as a plain string.
*
* Returns the new groups array.
*/
@ -53,8 +62,9 @@ export function addGroupWithCache(
cache: Map<string, object>,
groups: GroupEntry[],
groupName: ToolGroup,
modeSlug: string,
): GroupEntry[] {
const cached = cache.get(groupName)
const cached = cache.get(cacheKey(modeSlug, groupName))
if (cached) {
return [...groups, [groupName, cached] as GroupEntry]
}

View file

@ -12,22 +12,28 @@ import { syncCacheFromGroups, removeGroupWithCache, addGroupWithCache } from "./
* back on — without the cache, the MCP filter config (mcpServers,
* mcpDefaultPolicy) would be discarded on uncheck.
*/
export function useGroupOptionsCache(groups: GroupEntry[]) {
export function useGroupOptionsCache(groups: GroupEntry[], modeSlug: string) {
const groupOptionsCache = useRef<Map<string, object>>(new Map())
// Sync cache with external state: if external state has tuple
// entries, update the cache so that toggles preserve them.
useEffect(() => {
syncCacheFromGroups(groupOptionsCache.current, groups)
}, [groups])
syncCacheFromGroups(groupOptionsCache.current, groups, modeSlug)
}, [groups, modeSlug])
const removeGroup = useCallback((currentGroups: GroupEntry[], groupName: string): GroupEntry[] => {
return removeGroupWithCache(groupOptionsCache.current, currentGroups, groupName)
}, [])
const removeGroup = useCallback(
(currentGroups: GroupEntry[], groupName: string): GroupEntry[] => {
return removeGroupWithCache(groupOptionsCache.current, currentGroups, groupName, modeSlug)
},
[modeSlug],
)
const addGroup = useCallback((currentGroups: GroupEntry[], groupName: ToolGroup): GroupEntry[] => {
return addGroupWithCache(groupOptionsCache.current, currentGroups, groupName)
}, [])
const addGroup = useCallback(
(currentGroups: GroupEntry[], groupName: ToolGroup): GroupEntry[] => {
return addGroupWithCache(groupOptionsCache.current, currentGroups, groupName, modeSlug)
},
[modeSlug],
)
return { removeGroup, addGroup, cache: groupOptionsCache }
}

View file

@ -37,6 +37,7 @@ export interface ExtensionStateContextType extends ExtensionState {
showWelcome: boolean
theme: any
mcpServers: McpServer[]
mcpServersLoaded: boolean
currentCheckpoint?: string
currentTaskTodos?: TodoItem[] // Initial todos for the current task
filePaths: string[]
@ -272,6 +273,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [openedTabs, setOpenedTabs] = useState<Array<{ label: string; isActive: boolean; path?: string }>>([])
const [commands, setCommands] = useState<Command[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
const [mcpServersLoaded, setMcpServersLoaded] = useState(false)
const [currentCheckpoint, setCurrentCheckpoint] = useState<string>()
const [extensionRouterModels, setExtensionRouterModels] = useState<RouterModels | undefined>(undefined)
const [marketplaceItems, setMarketplaceItems] = useState<any[]>([])
@ -400,6 +402,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
}
case "mcpServers": {
setMcpServers(message.mcpServers ?? [])
setMcpServersLoaded(true)
break
}
case "currentCheckpointUpdated": {
@ -492,6 +495,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
showWelcome,
theme,
mcpServers,
mcpServersLoaded,
currentCheckpoint,
filePaths,
openedTabs,