mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-02 02:11:37 +00:00
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
395 lines
12 KiB
TypeScript
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()
|