Skip to content

Commit aa6bad8

Browse files
committed
fix: add auto-approval support for MCP resources (#5300)
1 parent 10ce509 commit aa6bad8

File tree

26 files changed

+732
-29
lines changed

26 files changed

+732
-29
lines changed

src/core/webview/webviewMessageHandler.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,23 @@ export const webviewMessageHandler = async (
881881
}
882882
break
883883
}
884+
case "toggleResourceAlwaysAllow": {
885+
try {
886+
await provider
887+
.getMcpHub()
888+
?.toggleResourceAlwaysAllow(
889+
message.serverName!,
890+
message.source as "global" | "project",
891+
message.resourceUri!,
892+
Boolean(message.alwaysAllow),
893+
)
894+
} catch (error) {
895+
provider.log(
896+
`Failed to toggle auto-approve for resource ${message.resourceUri}: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
897+
)
898+
}
899+
break
900+
}
884901
case "toggleToolEnabledForPrompt": {
885902
try {
886903
await provider

src/services/mcp/McpHub.ts

Lines changed: 174 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -885,7 +885,44 @@ export class McpHub {
885885
return []
886886
}
887887
const response = await connection.client.request({ method: "resources/list" }, ListResourcesResultSchema)
888-
return response?.resources || []
888+
889+
// Determine the actual source of the server
890+
const actualSource = connection.server.source || "global"
891+
let configPath: string
892+
let alwaysAllowResourcesConfig: string[] = []
893+
894+
// Read from the appropriate config file based on the actual source
895+
try {
896+
let serverConfigData: Record<string, any> = {}
897+
if (actualSource === "project") {
898+
// Get project MCP config path
899+
const projectMcpPath = await this.getProjectMcpPath()
900+
if (projectMcpPath) {
901+
configPath = projectMcpPath
902+
const content = await fs.readFile(configPath, "utf-8")
903+
serverConfigData = JSON.parse(content)
904+
}
905+
} else {
906+
// Get global MCP settings path
907+
configPath = await this.getMcpSettingsFilePath()
908+
const content = await fs.readFile(configPath, "utf-8")
909+
serverConfigData = JSON.parse(content)
910+
}
911+
if (serverConfigData) {
912+
alwaysAllowResourcesConfig = serverConfigData.mcpServers?.[serverName]?.alwaysAllowResources || []
913+
}
914+
} catch (error) {
915+
console.error(`Failed to read resource configuration for ${serverName}:`, error)
916+
// Continue with empty configs
917+
}
918+
919+
// Mark resources as always allowed based on settings
920+
const resources = (response?.resources || []).map((resource) => ({
921+
...resource,
922+
alwaysAllow: alwaysAllowResourcesConfig.includes(resource.uri),
923+
}))
924+
925+
return resources
889926
} catch (error) {
890927
// console.error(`Failed to fetch resources for ${serverName}:`, error)
891928
return []
@@ -905,7 +942,44 @@ export class McpHub {
905942
{ method: "resources/templates/list" },
906943
ListResourceTemplatesResultSchema,
907944
)
908-
return response?.resourceTemplates || []
945+
946+
// Determine the actual source of the server
947+
const actualSource = connection.server.source || "global"
948+
let configPath: string
949+
let alwaysAllowResourcesConfig: string[] = []
950+
951+
// Read from the appropriate config file based on the actual source
952+
try {
953+
let serverConfigData: Record<string, any> = {}
954+
if (actualSource === "project") {
955+
// Get project MCP config path
956+
const projectMcpPath = await this.getProjectMcpPath()
957+
if (projectMcpPath) {
958+
configPath = projectMcpPath
959+
const content = await fs.readFile(configPath, "utf-8")
960+
serverConfigData = JSON.parse(content)
961+
}
962+
} else {
963+
// Get global MCP settings path
964+
configPath = await this.getMcpSettingsFilePath()
965+
const content = await fs.readFile(configPath, "utf-8")
966+
serverConfigData = JSON.parse(content)
967+
}
968+
if (serverConfigData) {
969+
alwaysAllowResourcesConfig = serverConfigData.mcpServers?.[serverName]?.alwaysAllowResources || []
970+
}
971+
} catch (error) {
972+
console.error(`Failed to read resource template configuration for ${serverName}:`, error)
973+
// Continue with empty configs
974+
}
975+
976+
// Mark resource templates as always allowed based on settings
977+
const resourceTemplates = (response?.resourceTemplates || []).map((template) => ({
978+
...template,
979+
alwaysAllow: alwaysAllowResourcesConfig.includes(template.uriTemplate),
980+
}))
981+
982+
return resourceTemplates
909983
} catch (error) {
910984
// console.error(`Failed to fetch resource templates for ${serverName}:`, error)
911985
return []
@@ -1609,6 +1683,104 @@ export class McpHub {
16091683
}
16101684
}
16111685

1686+
async toggleResourceAlwaysAllow(
1687+
serverName: string,
1688+
source: "global" | "project",
1689+
resourceUri: string,
1690+
shouldAllow: boolean,
1691+
): Promise<void> {
1692+
try {
1693+
await this.updateServerResourceList(serverName, source, resourceUri, "alwaysAllowResources", shouldAllow)
1694+
} catch (error) {
1695+
this.showErrorMessage(
1696+
`Failed to toggle always allow for resource "${resourceUri}" on server "${serverName}" with source "${source}"`,
1697+
error,
1698+
)
1699+
throw error
1700+
}
1701+
}
1702+
1703+
/**
1704+
* Helper method to update the resource always allow list
1705+
* in the appropriate settings file.
1706+
* @param serverName The name of the server to update
1707+
* @param source Whether to update the global or project config
1708+
* @param resourceUri The URI of the resource to add or remove
1709+
* @param listName The name of the list to modify ("alwaysAllowResources")
1710+
* @param addResource Whether to add (true) or remove (false) the resource from the list
1711+
*/
1712+
private async updateServerResourceList(
1713+
serverName: string,
1714+
source: "global" | "project",
1715+
resourceUri: string,
1716+
listName: "alwaysAllowResources",
1717+
addResource: boolean,
1718+
): Promise<void> {
1719+
// Find the connection with matching name and source
1720+
const connection = this.findConnection(serverName, source)
1721+
1722+
if (!connection) {
1723+
throw new Error(`Server ${serverName} with source ${source} not found`)
1724+
}
1725+
1726+
// Determine the correct config path based on the source
1727+
let configPath: string
1728+
if (source === "project") {
1729+
// Get project MCP config path
1730+
const projectMcpPath = await this.getProjectMcpPath()
1731+
if (!projectMcpPath) {
1732+
throw new Error("Project MCP configuration file not found")
1733+
}
1734+
configPath = projectMcpPath
1735+
} else {
1736+
// Get global MCP settings path
1737+
configPath = await this.getMcpSettingsFilePath()
1738+
}
1739+
1740+
// Normalize path for cross-platform compatibility
1741+
// Use a consistent path format for both reading and writing
1742+
const normalizedPath = process.platform === "win32" ? configPath.replace(/\\/g, "/") : configPath
1743+
1744+
// Read the appropriate config file
1745+
const content = await fs.readFile(normalizedPath, "utf-8")
1746+
const config = JSON.parse(content)
1747+
1748+
if (!config.mcpServers) {
1749+
config.mcpServers = {}
1750+
}
1751+
1752+
if (!config.mcpServers[serverName]) {
1753+
config.mcpServers[serverName] = {
1754+
type: "stdio",
1755+
command: "node",
1756+
args: [], // Default to an empty array; can be set later if needed
1757+
}
1758+
}
1759+
1760+
if (!config.mcpServers[serverName][listName]) {
1761+
config.mcpServers[serverName][listName] = []
1762+
}
1763+
1764+
const targetList = config.mcpServers[serverName][listName]
1765+
const resourceIndex = targetList.indexOf(resourceUri)
1766+
1767+
if (addResource && resourceIndex === -1) {
1768+
targetList.push(resourceUri)
1769+
} else if (!addResource && resourceIndex !== -1) {
1770+
targetList.splice(resourceIndex, 1)
1771+
}
1772+
1773+
// Write config file first, then update UI only if successful
1774+
await fs.writeFile(normalizedPath, JSON.stringify(config, null, 2))
1775+
1776+
// Only update UI after successful config file write
1777+
if (connection) {
1778+
connection.server.resources = await this.fetchResourcesList(serverName, source)
1779+
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(serverName, source)
1780+
await this.notifyWebviewOfServerChanges()
1781+
}
1782+
}
1783+
16121784
async dispose(): Promise<void> {
16131785
// Prevent multiple disposals
16141786
if (this.isDisposed) {

src/shared/WebviewMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export interface WebviewMessage {
102102
| "restartMcpServer"
103103
| "refreshAllMcpServers"
104104
| "toggleToolAlwaysAllow"
105+
| "toggleResourceAlwaysAllow"
105106
| "toggleToolEnabledForPrompt"
106107
| "toggleMcpServer"
107108
| "updateMcpTimeout"
@@ -208,6 +209,7 @@ export interface WebviewMessage {
208209
audioType?: AudioType
209210
serverName?: string
210211
toolName?: string
212+
resourceUri?: string
211213
alwaysAllow?: boolean
212214
isEnabled?: boolean
213215
mode?: Mode

src/shared/mcp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ export type McpResource = {
3333
name: string
3434
mimeType?: string
3535
description?: string
36+
alwaysAllow?: boolean
3637
}
3738

3839
export type McpResourceTemplate = {
3940
uriTemplate: string
4041
name: string
4142
description?: string
4243
mimeType?: string
44+
alwaysAllow?: boolean
4345
}
4446

4547
export type McpResourceResponse = {

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { useDebounceEffect } from "@src/utils/useDebounceEffect"
1313
import type { ClineAsk, ClineMessage } from "@roo-code/types"
1414

1515
import { ClineSayBrowserAction, ClineSayTool, ExtensionMessage } from "@roo/ExtensionMessage"
16-
import { McpServer, McpTool } from "@roo/mcp"
16+
import { McpServer, McpTool, McpResource } from "@roo/mcp"
1717
import { findLast } from "@roo/array"
1818
import { FollowUpData, SuggestionItem } from "@roo-code/types"
1919
import { combineApiRequests } from "@roo/combineApiRequests"
@@ -895,27 +895,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
895895
return false
896896
}, [])
897897

898-
const isMcpToolAlwaysAllowed = useCallback(
899-
(message: ClineMessage | undefined) => {
900-
if (message?.type === "ask" && message.ask === "use_mcp_server") {
901-
if (!message.text) {
902-
return true
903-
}
904-
905-
const mcpServerUse = JSON.parse(message.text) as { type: string; serverName: string; toolName: string }
906-
907-
if (mcpServerUse.type === "use_mcp_tool") {
908-
const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
909-
const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName)
910-
return tool?.alwaysAllow || false
911-
}
912-
}
913-
914-
return false
915-
},
916-
[mcpServers],
917-
)
918-
919898
// Get the command decision using unified validation logic
920899
const getCommandDecisionForMessage = useCallback(
921900
(message: ClineMessage | undefined): CommandDecision => {
@@ -974,7 +953,57 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
974953
}
975954

976955
if (message.ask === "use_mcp_server") {
977-
return alwaysAllowMcp && isMcpToolAlwaysAllowed(message)
956+
if (!alwaysAllowMcp) {
957+
return false
958+
}
959+
960+
// If no text, allow it
961+
if (!message.text) {
962+
return true
963+
}
964+
965+
// Parse the MCP server use
966+
const mcpServerUse = JSON.parse(message.text) as {
967+
type: string
968+
serverName: string
969+
toolName?: string
970+
uri?: string
971+
}
972+
973+
if (mcpServerUse.type === "use_mcp_tool") {
974+
const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
975+
const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName)
976+
return tool?.alwaysAllow || false
977+
}
978+
979+
if (mcpServerUse.type === "access_mcp_resource") {
980+
const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
981+
const resource = server?.resources?.find((r: McpResource) => r.uri === mcpServerUse.uri)
982+
983+
if (resource?.alwaysAllow) {
984+
return true
985+
}
986+
987+
// Check resource templates
988+
if (server?.resourceTemplates && mcpServerUse.uri) {
989+
for (const template of server.resourceTemplates) {
990+
// Convert template pattern to regex with proper escaping
991+
const pattern = template.uriTemplate
992+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") // Escape special regex chars
993+
.replace(/\\\{[^}]+\\\}/g, "[^/]+") // Match path segments, not everything
994+
const regex = new RegExp(`^${pattern}$`)
995+
if (regex.test(mcpServerUse.uri) && template.alwaysAllow) {
996+
return true
997+
}
998+
}
999+
}
1000+
1001+
// Resource not found or not allowed
1002+
return false
1003+
}
1004+
1005+
// Unknown type, don't auto-approve
1006+
return false
9781007
}
9791008

9801009
if (message.ask === "command") {
@@ -1049,7 +1078,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
10491078
alwaysAllowExecute,
10501079
isAllowedCommand,
10511080
alwaysAllowMcp,
1052-
isMcpToolAlwaysAllowed,
1081+
mcpServers,
10531082
alwaysAllowModeSwitch,
10541083
alwaysAllowFollowupQuestions,
10551084
alwaysAllowSubtasks,
@@ -1522,6 +1551,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
15221551
clineAsk,
15231552
enableButtons,
15241553
handlePrimaryButtonClick,
1554+
autoApprovalEnabled,
15251555
alwaysAllowBrowser,
15261556
alwaysAllowReadOnly,
15271557
alwaysAllowReadOnlyOutsideWorkspace,
@@ -1530,10 +1560,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
15301560
alwaysAllowExecute,
15311561
followupAutoApproveTimeoutMs,
15321562
alwaysAllowMcp,
1563+
mcpServers,
15331564
messages,
15341565
allowedCommands,
15351566
deniedCommands,
1536-
mcpServers,
15371567
isAutoApproved,
15381568
lastMessage,
15391569
writeDelayMs,

0 commit comments

Comments
 (0)