Roo-Code/src/core/tools/UseMcpToolTool.ts
Stefan Vetter 79de65a8f5 feat: implement per-mode MCP server/tool filtering
Adds granular per-mode control over which MCP servers and tools are available, replacing the previous all-or-nothing 'mcp' group behavior.

Schema (packages/types/src/mode.ts): - Add mcpServerFilterSchema with disabled/allowedTools/disabledTools - Add mcpDefaultPolicy ('allow'|'deny') for deny-by-default support - Add mcpGroupOptionsSchema extending group options for MCP tuples - Add superRefine validation rejecting MCP options on non-mcp groups

Core filtering (src/utils/mcp-filter.ts): - getMcpFilterForMode() resolves MCP config for a mode - isMcpServerAllowedForMode() checks server access with policy - isMcpToolAllowedForMode() checks tool access with allowlist/blocklist - Inlined getGroupName to avoid vscode import chain (ISSUE-16)

Prerequisite fix (src/utils/mcp-name.ts): - Fix normalizeForComparison regex: /[-\s]+/g -> /[-\s]/g (ISSUE-10) - Add toLowerCase() for case-insensitive matching

Prompt-level filtering: - filter-tools-for-mode.ts: Filter MCP tools from system prompt - build-tools.ts: Filter MCP tools for Gemini native function calling - ISSUE-19: Document native tools remain unfiltered for Gemini

Execution-time guards: - validateToolUse.ts: Replace blanket MCP allow with filter checks   - Server-level guard for use_mcp_tool/access_mcp_resource   - Tool-level guard for dynamic mcp--server--tool names   - ISSUE-21: Tool-level check for use_mcp_tool when tool_name available - presentAssistantMessage.ts mcp_tool_use: Add filter using cline.taskMode - ISSUE-17: validateToolUse call uses cline.taskMode (frozen at task start)   instead of state.mode (live UI mode) - ISSUE-20: Remove dead ?? defaultModeSlug fallback

Defense-in-depth: - UseMcpToolTool.execute(): Server + tool level filter before execution - AccessMcpResourceTool.execute(): Server level filter before execution - FLAG-E: Document 10-second TTL cache in CustomModesManager

UI fix (webview-ui): - ModesView.tsx: Cache group tuple options on toggle-off, restore on   toggle-on to prevent MCP config data loss (ISSUE-9/ISSUE-13) - Extract pure caching functions to groupOptionsCache.ts

Tests: 89 new tests across 11 files, all passing
2026-04-03 14:12:18 +02:00

395 lines
12 KiB
TypeScript

import type { ClineAskUseMcpServer, McpExecutionStatus } from "@roo-code/types"
import { Task } from "../task/Task"
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 { BaseTool, ToolCallbacks } from "./BaseTool"
interface UseMcpToolParams {
server_name: string
tool_name: string
arguments?: Record<string, unknown>
}
type ValidationResult =
| { isValid: false }
| {
isValid: true
serverName: string
toolName: string
parsedArguments?: Record<string, unknown>
}
export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
readonly name = "use_mcp_tool" as const
async execute(params: UseMcpToolParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { askApproval, handleError, pushToolResult } = callbacks
try {
// Validate parameters
const validation = await this.validateParams(task, params, pushToolResult)
if (!validation.isValid) {
return
}
const { serverName, toolName, parsedArguments } = validation
// 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()
if (!isMcpServerAllowedForMode(serverName, task.taskMode, customModes)) {
task.consecutiveMistakeCount++
task.recordToolError("use_mcp_tool")
await task.say("error", 'MCP server "' + serverName + '" is not allowed in ' + task.taskMode + " mode")
pushToolResult(
formatResponse.toolError(
'MCP server "' + serverName + '" is not allowed in ' + task.taskMode + " mode",
),
)
return
}
if (!isMcpToolAllowedForMode(serverName, toolName, task.taskMode, customModes)) {
task.consecutiveMistakeCount++
task.recordToolError("use_mcp_tool")
await task.say(
"error",
'MCP tool "' +
toolName +
'" on server "' +
serverName +
'" is not allowed in ' +
task.taskMode +
" mode",
)
pushToolResult(
formatResponse.toolError(
'MCP tool "' +
toolName +
'" on server "' +
serverName +
'" is not allowed in ' +
task.taskMode +
" mode",
),
)
return
}
// Validate that the tool exists on the server
const toolValidation = await this.validateToolExists(task, serverName, toolName, pushToolResult)
if (!toolValidation.isValid) {
return
}
// Use the resolved tool name (original name from the server) for MCP calls
// This handles cases where models mangle hyphens to underscores
const resolvedToolName = toolValidation.resolvedToolName ?? toolName
// Reset mistake count on successful validation
task.consecutiveMistakeCount = 0
// Get user approval
const completeMessage = JSON.stringify({
type: "use_mcp_tool",
serverName,
toolName: resolvedToolName,
arguments: params.arguments ? JSON.stringify(params.arguments) : undefined,
} satisfies ClineAskUseMcpServer)
const executionId = task.lastMessageTs?.toString() ?? Date.now().toString()
const didApprove = await askApproval("use_mcp_server", completeMessage)
if (!didApprove) {
return
}
// Execute the tool and process results
await this.executeToolAndProcessResult(
task,
serverName,
resolvedToolName,
parsedArguments,
executionId,
pushToolResult,
)
} catch (error) {
await handleError("executing MCP tool", error as Error)
}
}
override async handlePartial(task: Task, block: ToolUse<"use_mcp_tool">): Promise<void> {
const params = block.params
const partialMessage = JSON.stringify({
type: "use_mcp_tool",
serverName: params.server_name ?? "",
toolName: params.tool_name ?? "",
arguments: params.arguments,
} satisfies ClineAskUseMcpServer)
await task.ask("use_mcp_server", partialMessage, true).catch(() => {})
}
private async validateParams(
task: Task,
params: UseMcpToolParams,
pushToolResult: (content: string) => void,
): Promise<ValidationResult> {
if (!params.server_name) {
task.consecutiveMistakeCount++
task.recordToolError("use_mcp_tool")
pushToolResult(await task.sayAndCreateMissingParamError("use_mcp_tool", "server_name"))
return { isValid: false }
}
if (!params.tool_name) {
task.consecutiveMistakeCount++
task.recordToolError("use_mcp_tool")
pushToolResult(await task.sayAndCreateMissingParamError("use_mcp_tool", "tool_name"))
return { isValid: false }
}
// Native-only: arguments are already a structured object.
let parsedArguments: Record<string, unknown> | undefined
if (params.arguments !== undefined) {
if (typeof params.arguments !== "object" || params.arguments === null || Array.isArray(params.arguments)) {
task.consecutiveMistakeCount++
task.recordToolError("use_mcp_tool")
await task.say("error", t("mcp:errors.invalidJsonArgument", { toolName: params.tool_name }))
task.didToolFailInCurrentTurn = true
pushToolResult(
formatResponse.toolError(
formatResponse.invalidMcpToolArgumentError(params.server_name, params.tool_name),
),
)
return { isValid: false }
}
parsedArguments = params.arguments
}
return {
isValid: true,
serverName: params.server_name,
toolName: params.tool_name,
parsedArguments,
}
}
private async validateToolExists(
task: Task,
serverName: string,
toolName: string,
pushToolResult: (content: string) => void,
): Promise<{ isValid: boolean; availableTools?: string[]; resolvedToolName?: string }> {
try {
// Get the MCP hub to access server information
const provider = task.providerRef.deref()
const mcpHub = provider?.getMcpHub()
if (!mcpHub) {
// If we can't get the MCP hub, we can't validate, so proceed with caution
return { isValid: true }
}
// Get all servers to find the specific one
const servers = mcpHub.getAllServers()
const server = servers.find((s) => s.name === serverName)
if (!server) {
// Fail fast when server is unknown
const availableServersArray = servers.map((s) => s.name)
const availableServers =
availableServersArray.length > 0 ? availableServersArray.join(", ") : "No servers available"
task.consecutiveMistakeCount++
task.recordToolError("use_mcp_tool")
await task.say("error", t("mcp:errors.serverNotFound", { serverName, availableServers }))
task.didToolFailInCurrentTurn = true
pushToolResult(formatResponse.unknownMcpServerError(serverName, availableServersArray))
return { isValid: false, availableTools: [] }
}
// Check if the server has tools defined
if (!server.tools || server.tools.length === 0) {
// No tools available on this server
task.consecutiveMistakeCount++
task.recordToolError("use_mcp_tool")
await task.say(
"error",
t("mcp:errors.toolNotFound", {
toolName,
serverName,
availableTools: "No tools available",
}),
)
task.didToolFailInCurrentTurn = true
pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, []))
return { isValid: false, availableTools: [] }
}
// Check if the requested tool exists (using fuzzy matching to handle model mangling of hyphens)
const tool = server.tools.find((t) => toolNamesMatch(t.name, toolName))
if (!tool) {
// Tool not found - provide list of available tools
const availableToolNames = server.tools.map((tool) => tool.name)
task.consecutiveMistakeCount++
task.recordToolError("use_mcp_tool")
await task.say(
"error",
t("mcp:errors.toolNotFound", {
toolName,
serverName,
availableTools: availableToolNames.join(", "),
}),
)
task.didToolFailInCurrentTurn = true
pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, availableToolNames))
return { isValid: false, availableTools: availableToolNames }
}
// Check if the tool is disabled (enabledForPrompt is false)
if (tool.enabledForPrompt === false) {
// Tool is disabled - only show enabled tools
const enabledTools = server.tools.filter((t) => t.enabledForPrompt !== false)
const enabledToolNames = enabledTools.map((t) => t.name)
task.consecutiveMistakeCount++
task.recordToolError("use_mcp_tool")
await task.say(
"error",
t("mcp:errors.toolDisabled", {
toolName,
serverName,
availableTools:
enabledToolNames.length > 0 ? enabledToolNames.join(", ") : "No enabled tools available",
}),
)
task.didToolFailInCurrentTurn = true
pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, enabledToolNames))
return { isValid: false, availableTools: enabledToolNames }
}
// Tool exists and is enabled - return the original tool name for use with the MCP server
return { isValid: true, availableTools: server.tools.map((t) => t.name), resolvedToolName: tool.name }
} catch (error) {
// If there's an error during validation, log it but don't block the tool execution
// The actual tool call might still fail with a proper error
console.error("Error validating MCP tool existence:", error)
return { isValid: true }
}
}
private async sendExecutionStatus(task: Task, status: McpExecutionStatus): Promise<void> {
const clineProvider = await task.providerRef.deref()
clineProvider?.postMessageToWebview({
type: "mcpExecutionStatus",
text: JSON.stringify(status),
})
}
private processToolContent(toolResult: any): { text: string; images: string[] } {
if (!toolResult?.content || toolResult.content.length === 0) {
return { text: "", images: [] }
}
const images: string[] = []
const textContent = toolResult.content
.map((item: any) => {
if (item.type === "text") {
return item.text
}
if (item.type === "resource") {
const { blob: _, ...rest } = item.resource
return JSON.stringify(rest, null, 2)
}
if (item.type === "image") {
// Handle image content (MCP image content has mimeType and data properties)
if (item.mimeType && item.data) {
if (item.data.startsWith("data:")) {
images.push(item.data)
} else {
images.push(`data:${item.mimeType};base64,${item.data}`)
}
}
return ""
}
return ""
})
.filter(Boolean)
.join("\n\n")
return { text: textContent, images }
}
private async executeToolAndProcessResult(
task: Task,
serverName: string,
toolName: string,
parsedArguments: Record<string, unknown> | undefined,
executionId: string,
pushToolResult: (content: string | Array<any>) => void,
): Promise<void> {
await task.say("mcp_server_request_started")
// Send started status
await this.sendExecutionStatus(task, {
executionId,
status: "started",
serverName,
toolName,
})
const toolResult = await task.providerRef.deref()?.getMcpHub()?.callTool(serverName, toolName, parsedArguments)
let toolResultPretty = "(No response)"
let images: string[] = []
if (toolResult) {
const { text: outputText, images: extractedImages } = this.processToolContent(toolResult)
images = extractedImages
if (outputText || images.length > 0) {
await this.sendExecutionStatus(task, {
executionId,
status: "output",
response: outputText || (images.length > 0 ? `[${images.length} image(s)]` : ""),
})
toolResultPretty =
(toolResult.isError ? "Error:\n" : "") +
(outputText || (images.length > 0 ? `[${images.length} image(s) received]` : ""))
}
// Send completion status
await this.sendExecutionStatus(task, {
executionId,
status: toolResult.isError ? "error" : "completed",
response: toolResultPretty,
error: toolResult.isError ? "Error executing MCP tool" : undefined,
})
} else {
// Send error status if no result
await this.sendExecutionStatus(task, {
executionId,
status: "error",
error: "No response from MCP server",
})
}
await task.say("mcp_server_response", toolResultPretty, images)
pushToolResult(formatResponse.toolResult(toolResultPretty, images))
}
}
export const useMcpToolTool = new UseMcpToolTool()