mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: Add VSCode MCP scope support
- Add 'vscode' as a new server source type alongside 'global' and 'project' - Implement VS Code settings discovery logic in McpHub - Read from both 'mcpServers' and 'github.copilot.agent.mcpServers' configurations - Add precedence logic (Project > VSCode > Global) - Update UI components to display VSCode-sourced servers with badges - Mark VSCode servers as read-only (cannot be edited/deleted) - Allow enable/disable functionality for VSCode servers - Update TypeScript types to support the new source - Add comprehensive tests for VSCode MCP scope functionality Fixes #8097
This commit is contained in:
parent
a255c95bd0
commit
9ccae6445c
9 changed files with 504 additions and 90 deletions
|
|
@ -11,7 +11,8 @@
|
|||
"disconnect_servers_partial": "Failed to disconnect {{count}} MCP server(s). Check the output for details.",
|
||||
"toolNotFound": "Tool '{{toolName}}' does not exist on server '{{serverName}}'. Available tools: {{availableTools}}",
|
||||
"serverNotFound": "MCP server '{{serverName}}' is not configured. Available servers: {{availableServers}}",
|
||||
"toolDisabled": "Tool '{{toolName}}' on server '{{serverName}}' is disabled. Available enabled tools: {{availableTools}}"
|
||||
"toolDisabled": "Tool '{{toolName}}' on server '{{serverName}}' is disabled. Available enabled tools: {{availableTools}}",
|
||||
"vscode_servers_readonly": "VSCode MCP servers are managed by VS Code and cannot be modified from Roo. Please edit them in VS Code settings."
|
||||
},
|
||||
"info": {
|
||||
"server_restarting": "Restarting {{serverName}} MCP server...",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import delay from "delay"
|
|||
import deepEqual from "fast-deep-equal"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { t } from "../../i18n"
|
||||
|
|
@ -146,19 +147,23 @@ export class McpHub {
|
|||
private settingsWatcher?: vscode.FileSystemWatcher
|
||||
private fileWatchers: Map<string, FSWatcher[]> = new Map()
|
||||
private projectMcpWatcher?: vscode.FileSystemWatcher
|
||||
private vscodeMcpWatcher?: vscode.FileSystemWatcher
|
||||
private isDisposed: boolean = false
|
||||
connections: McpConnection[] = []
|
||||
isConnecting: boolean = false
|
||||
private refCount: number = 0 // Reference counter for active clients
|
||||
private configChangeDebounceTimers: Map<string, NodeJS.Timeout> = new Map()
|
||||
private vscodeMcpServers: Map<string, any> = new Map() // Cache for VSCode MCP servers
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
this.watchMcpSettingsFile()
|
||||
this.watchProjectMcpFile().catch(console.error)
|
||||
this.watchVSCodeMcpSettings().catch(console.error)
|
||||
this.setupWorkspaceFoldersWatcher()
|
||||
this.initializeGlobalMcpServers()
|
||||
this.initializeProjectMcpServers()
|
||||
this.initializeVSCodeMcpServers().catch(console.error)
|
||||
}
|
||||
/**
|
||||
* Registers a client (e.g., ClineProvider) using this hub.
|
||||
|
|
@ -270,6 +275,7 @@ export class McpHub {
|
|||
vscode.workspace.onDidChangeWorkspaceFolders(async () => {
|
||||
await this.updateProjectMcpServers()
|
||||
await this.watchProjectMcpFile()
|
||||
await this.updateVSCodeMcpServers()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -277,7 +283,7 @@ export class McpHub {
|
|||
/**
|
||||
* Debounced wrapper for handling config file changes
|
||||
*/
|
||||
private debounceConfigChange(filePath: string, source: "global" | "project"): void {
|
||||
private debounceConfigChange(filePath: string, source: "global" | "project" | "vscode"): void {
|
||||
const key = `${source}-${filePath}`
|
||||
|
||||
// Clear existing timer if any
|
||||
|
|
@ -295,7 +301,7 @@ export class McpHub {
|
|||
this.configChangeDebounceTimers.set(key, timer)
|
||||
}
|
||||
|
||||
private async handleConfigFileChange(filePath: string, source: "global" | "project"): Promise<void> {
|
||||
private async handleConfigFileChange(filePath: string, source: "global" | "project" | "vscode"): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
let config: any
|
||||
|
|
@ -502,10 +508,14 @@ export class McpHub {
|
|||
this.disposables.push(vscode.Disposable.from(changeDisposable, createDisposable, this.settingsWatcher))
|
||||
}
|
||||
|
||||
private async initializeMcpServers(source: "global" | "project"): Promise<void> {
|
||||
private async initializeMcpServers(source: "global" | "project" | "vscode"): Promise<void> {
|
||||
try {
|
||||
const configPath =
|
||||
source === "global" ? await this.getMcpSettingsFilePath() : await this.getProjectMcpPath()
|
||||
source === "global"
|
||||
? await this.getMcpSettingsFilePath()
|
||||
: source === "project"
|
||||
? await this.getProjectMcpPath()
|
||||
: null // VSCode settings are handled differently
|
||||
|
||||
if (!configPath) {
|
||||
return
|
||||
|
|
@ -568,6 +578,91 @@ export class McpHub {
|
|||
await this.initializeMcpServers("project")
|
||||
}
|
||||
|
||||
// Get VSCode MCP configuration path
|
||||
private async getVSCodeMcpPath(): Promise<string | null> {
|
||||
try {
|
||||
// VS Code stores MCP settings in user settings
|
||||
const config = vscode.workspace.getConfiguration("mcpServers")
|
||||
if (config) {
|
||||
return "vscode-settings" // Virtual path indicator
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize VSCode MCP servers
|
||||
private async initializeVSCodeMcpServers(): Promise<void> {
|
||||
try {
|
||||
// Read VS Code's MCP configuration
|
||||
const config = vscode.workspace.getConfiguration("mcpServers")
|
||||
const servers = config
|
||||
? Object.entries(config).reduce(
|
||||
(acc, [key, value]) => {
|
||||
// Filter out VS Code's internal properties
|
||||
if (typeof value === "object" && !key.startsWith("_")) {
|
||||
acc[key] = value
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
)
|
||||
: {}
|
||||
|
||||
// Also check for GitHub Copilot Agent servers
|
||||
const copilotConfig = vscode.workspace.getConfiguration("github.copilot.agent.mcpServers")
|
||||
const copilotServers = copilotConfig
|
||||
? Object.entries(copilotConfig).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (typeof value === "object" && !key.startsWith("_")) {
|
||||
acc[`copilot-${key}`] = value // Prefix to avoid conflicts
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
)
|
||||
: {}
|
||||
|
||||
// Merge both sources
|
||||
const allVSCodeServers = { ...servers, ...copilotServers }
|
||||
|
||||
// Cache the servers for later use
|
||||
this.vscodeMcpServers.clear()
|
||||
for (const [name, config] of Object.entries(allVSCodeServers)) {
|
||||
this.vscodeMcpServers.set(name, config)
|
||||
}
|
||||
|
||||
// Update connections with VSCode servers
|
||||
await this.updateServerConnections(allVSCodeServers, "vscode", false)
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize VSCode MCP servers:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Update VSCode MCP servers
|
||||
private async updateVSCodeMcpServers(): Promise<void> {
|
||||
await this.initializeVSCodeMcpServers()
|
||||
}
|
||||
|
||||
// Watch VSCode MCP settings for changes
|
||||
private async watchVSCodeMcpSettings(): Promise<void> {
|
||||
// Skip if test environment is detected
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return
|
||||
}
|
||||
|
||||
// Watch for VS Code configuration changes
|
||||
const disposable = vscode.workspace.onDidChangeConfiguration(async (e) => {
|
||||
if (e.affectsConfiguration("mcpServers") || e.affectsConfiguration("github.copilot.agent.mcpServers")) {
|
||||
// Debounce the update
|
||||
this.debounceConfigChange("vscode-settings", "vscode")
|
||||
}
|
||||
})
|
||||
|
||||
this.disposables.push(disposable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a placeholder connection for disabled servers or when MCP is globally disabled
|
||||
* @param name The server name
|
||||
|
|
@ -579,7 +674,7 @@ export class McpHub {
|
|||
private createPlaceholderConnection(
|
||||
name: string,
|
||||
config: z.infer<typeof ServerConfigSchema>,
|
||||
source: "global" | "project",
|
||||
source: "global" | "project" | "vscode",
|
||||
reason: DisableReason,
|
||||
): DisconnectedMcpConnection {
|
||||
return {
|
||||
|
|
@ -614,7 +709,7 @@ export class McpHub {
|
|||
private async connectToServer(
|
||||
name: string,
|
||||
config: z.infer<typeof ServerConfigSchema>,
|
||||
source: "global" | "project" = "global",
|
||||
source: "global" | "project" | "vscode" = "global",
|
||||
): Promise<void> {
|
||||
// Remove existing connection if it exists with the same source
|
||||
await this.deleteConnection(name, source)
|
||||
|
|
@ -628,8 +723,8 @@ export class McpHub {
|
|||
return
|
||||
}
|
||||
|
||||
// Skip connecting to disabled servers
|
||||
if (config.disabled) {
|
||||
// Skip connecting to disabled servers (VSCode servers can't be disabled individually)
|
||||
if (config.disabled && source !== "vscode") {
|
||||
// Still create a connection object to track the server, but don't actually connect
|
||||
const connection = this.createPlaceholderConnection(name, config, source, DisableReason.SERVER_DISABLED)
|
||||
this.connections.push(connection)
|
||||
|
|
@ -819,9 +914,10 @@ export class McpHub {
|
|||
name,
|
||||
config: JSON.stringify(configInjected),
|
||||
status: "connecting",
|
||||
disabled: configInjected.disabled,
|
||||
disabled: source === "vscode" ? false : configInjected.disabled, // VSCode servers can't be disabled
|
||||
source,
|
||||
projectPath: source === "project" ? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath : undefined,
|
||||
readOnly: source === "vscode", // Mark VSCode servers as read-only
|
||||
errorHistory: [],
|
||||
},
|
||||
client,
|
||||
|
|
@ -883,26 +979,32 @@ export class McpHub {
|
|||
* @param source Optional source to filter by (global or project)
|
||||
* @returns The matching connection or undefined if not found
|
||||
*/
|
||||
private findConnection(serverName: string, source?: "global" | "project"): McpConnection | undefined {
|
||||
private findConnection(serverName: string, source?: "global" | "project" | "vscode"): McpConnection | undefined {
|
||||
// If source is specified, only find servers with that source
|
||||
if (source !== undefined) {
|
||||
return this.connections.find((conn) => conn.server.name === serverName && conn.server.source === source)
|
||||
}
|
||||
|
||||
// If no source is specified, first look for project servers, then global servers
|
||||
// This ensures that when servers have the same name, project servers are prioritized
|
||||
// If no source is specified, check in precedence order: project > vscode > global
|
||||
// This ensures proper precedence when servers have the same name
|
||||
const projectConn = this.connections.find(
|
||||
(conn) => conn.server.name === serverName && conn.server.source === "project",
|
||||
)
|
||||
if (projectConn) return projectConn
|
||||
|
||||
// If no project server is found, look for global servers
|
||||
// Check for VSCode servers
|
||||
const vscodeConn = this.connections.find(
|
||||
(conn) => conn.server.name === serverName && conn.server.source === "vscode",
|
||||
)
|
||||
if (vscodeConn) return vscodeConn
|
||||
|
||||
// If no project or VSCode server is found, look for global servers
|
||||
return this.connections.find(
|
||||
(conn) => conn.server.name === serverName && (conn.server.source === "global" || !conn.server.source),
|
||||
)
|
||||
}
|
||||
|
||||
private async fetchToolsList(serverName: string, source?: "global" | "project"): Promise<McpTool[]> {
|
||||
private async fetchToolsList(serverName: string, source?: "global" | "project" | "vscode"): Promise<McpTool[]> {
|
||||
try {
|
||||
// Use the helper method to find the connection
|
||||
const connection = this.findConnection(serverName, source)
|
||||
|
|
@ -959,7 +1061,10 @@ export class McpHub {
|
|||
}
|
||||
}
|
||||
|
||||
private async fetchResourcesList(serverName: string, source?: "global" | "project"): Promise<McpResource[]> {
|
||||
private async fetchResourcesList(
|
||||
serverName: string,
|
||||
source?: "global" | "project" | "vscode",
|
||||
): Promise<McpResource[]> {
|
||||
try {
|
||||
const connection = this.findConnection(serverName, source)
|
||||
if (!connection || connection.type !== "connected") {
|
||||
|
|
@ -975,7 +1080,7 @@ export class McpHub {
|
|||
|
||||
private async fetchResourceTemplatesList(
|
||||
serverName: string,
|
||||
source?: "global" | "project",
|
||||
source?: "global" | "project" | "vscode",
|
||||
): Promise<McpResourceTemplate[]> {
|
||||
try {
|
||||
const connection = this.findConnection(serverName, source)
|
||||
|
|
@ -993,7 +1098,7 @@ export class McpHub {
|
|||
}
|
||||
}
|
||||
|
||||
async deleteConnection(name: string, source?: "global" | "project"): Promise<void> {
|
||||
async deleteConnection(name: string, source?: "global" | "project" | "vscode"): Promise<void> {
|
||||
// Clean up file watchers for this server
|
||||
this.removeFileWatchersForServer(name)
|
||||
|
||||
|
|
@ -1023,7 +1128,7 @@ export class McpHub {
|
|||
|
||||
async updateServerConnections(
|
||||
newServers: Record<string, any>,
|
||||
source: "global" | "project" = "global",
|
||||
source: "global" | "project" | "vscode" = "global",
|
||||
manageConnectingState: boolean = true,
|
||||
): Promise<void> {
|
||||
if (manageConnectingState) {
|
||||
|
|
@ -1093,7 +1198,7 @@ export class McpHub {
|
|||
private setupFileWatcher(
|
||||
name: string,
|
||||
config: z.infer<typeof ServerConfigSchema>,
|
||||
source: "global" | "project" = "global",
|
||||
source: "global" | "project" | "vscode" = "global",
|
||||
) {
|
||||
// Initialize an empty array for this server if it doesn't exist
|
||||
if (!this.fileWatchers.has(name)) {
|
||||
|
|
@ -1166,7 +1271,7 @@ export class McpHub {
|
|||
}
|
||||
}
|
||||
|
||||
async restartConnection(serverName: string, source?: "global" | "project"): Promise<void> {
|
||||
async restartConnection(serverName: string, source?: "global" | "project" | "vscode"): Promise<void> {
|
||||
this.isConnecting = true
|
||||
|
||||
// Check if MCP is globally enabled
|
||||
|
|
@ -1267,6 +1372,7 @@ export class McpHub {
|
|||
// This ensures proper initialization including fetching tools, resources, etc.
|
||||
await this.initializeMcpServers("global")
|
||||
await this.initializeMcpServers("project")
|
||||
await this.initializeVSCodeMcpServers()
|
||||
|
||||
await delay(100)
|
||||
|
||||
|
|
@ -1298,25 +1404,44 @@ export class McpHub {
|
|||
}
|
||||
}
|
||||
|
||||
// Sort connections: first project servers in their defined order, then global servers in their defined order
|
||||
// This ensures that when servers have the same name, project servers are prioritized
|
||||
// Sort connections: project > vscode > global, maintaining order within each group
|
||||
const sortedConnections = [...this.connections].sort((a, b) => {
|
||||
const aIsGlobal = a.server.source === "global" || !a.server.source
|
||||
const bIsGlobal = b.server.source === "global" || !b.server.source
|
||||
// Define source priority (lower number = higher priority)
|
||||
const sourcePriority = (source: string | undefined) => {
|
||||
switch (source) {
|
||||
case "project":
|
||||
return 0
|
||||
case "vscode":
|
||||
return 1
|
||||
case "global":
|
||||
case undefined:
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
// If both are global or both are project, sort by their respective order
|
||||
if (aIsGlobal && bIsGlobal) {
|
||||
const aPriority = sourcePriority(a.server.source)
|
||||
const bPriority = sourcePriority(b.server.source)
|
||||
|
||||
// If different sources, sort by priority
|
||||
if (aPriority !== bPriority) {
|
||||
return aPriority - bPriority
|
||||
}
|
||||
|
||||
// If same source, maintain their defined order
|
||||
if (a.server.source === "global" || !a.server.source) {
|
||||
const indexA = globalServerOrder.indexOf(a.server.name)
|
||||
const indexB = globalServerOrder.indexOf(b.server.name)
|
||||
return indexA - indexB
|
||||
} else if (!aIsGlobal && !bIsGlobal) {
|
||||
} else if (a.server.source === "project") {
|
||||
const indexA = projectServerOrder.indexOf(a.server.name)
|
||||
const indexB = projectServerOrder.indexOf(b.server.name)
|
||||
return indexA - indexB
|
||||
}
|
||||
|
||||
// Project servers come before global servers (reversed from original)
|
||||
return aIsGlobal ? 1 : -1
|
||||
// For VSCode servers, maintain discovery order
|
||||
return 0
|
||||
})
|
||||
|
||||
// Send sorted servers to webview
|
||||
|
|
@ -1345,8 +1470,39 @@ export class McpHub {
|
|||
public async toggleServerDisabled(
|
||||
serverName: string,
|
||||
disabled: boolean,
|
||||
source?: "global" | "project",
|
||||
source?: "global" | "project" | "vscode",
|
||||
): Promise<void> {
|
||||
// VSCode servers can only be enabled/disabled globally in VS Code settings
|
||||
if (source === "vscode") {
|
||||
const connection = this.findConnection(serverName, source)
|
||||
if (connection) {
|
||||
// Update the local state to reflect the change
|
||||
connection.server.disabled = disabled
|
||||
|
||||
// Store the preference locally (won't affect VS Code's actual config)
|
||||
// This allows users to temporarily disable VSCode servers within Roo
|
||||
if (disabled) {
|
||||
// Disconnect the server
|
||||
await this.deleteConnection(serverName, source)
|
||||
// Re-add as disabled placeholder
|
||||
const config = JSON.parse(connection.server.config)
|
||||
const placeholderConn = this.createPlaceholderConnection(
|
||||
serverName,
|
||||
config,
|
||||
source,
|
||||
DisableReason.SERVER_DISABLED,
|
||||
)
|
||||
this.connections.push(placeholderConn)
|
||||
} else {
|
||||
// Reconnect the server
|
||||
const config = JSON.parse(connection.server.config)
|
||||
await this.connectToServer(serverName, config, source)
|
||||
}
|
||||
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Find the connection to determine if it's a global or project server
|
||||
const connection = this.findConnection(serverName, source)
|
||||
|
|
@ -1406,8 +1562,12 @@ export class McpHub {
|
|||
private async updateServerConfig(
|
||||
serverName: string,
|
||||
configUpdate: Record<string, any>,
|
||||
source: "global" | "project" = "global",
|
||||
source: "global" | "project" | "vscode" = "global",
|
||||
): Promise<void> {
|
||||
// VSCode servers are read-only
|
||||
if (source === "vscode") {
|
||||
throw new Error("VSCode MCP servers are read-only and cannot be modified")
|
||||
}
|
||||
// Determine which config file to update
|
||||
let configPath: string
|
||||
if (source === "project") {
|
||||
|
|
@ -1469,8 +1629,13 @@ export class McpHub {
|
|||
public async updateServerTimeout(
|
||||
serverName: string,
|
||||
timeout: number,
|
||||
source?: "global" | "project",
|
||||
source?: "global" | "project" | "vscode",
|
||||
): Promise<void> {
|
||||
// VSCode servers are read-only
|
||||
if (source === "vscode") {
|
||||
vscode.window.showWarningMessage(t("mcp:errors.vscode_servers_readonly"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Find the connection to determine if it's a global or project server
|
||||
const connection = this.findConnection(serverName, source)
|
||||
|
|
@ -1488,7 +1653,12 @@ export class McpHub {
|
|||
}
|
||||
}
|
||||
|
||||
public async deleteServer(serverName: string, source?: "global" | "project"): Promise<void> {
|
||||
public async deleteServer(serverName: string, source?: "global" | "project" | "vscode"): Promise<void> {
|
||||
// VSCode servers are read-only and cannot be deleted
|
||||
if (source === "vscode") {
|
||||
vscode.window.showWarningMessage(t("mcp:errors.vscode_servers_readonly"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Find the connection to determine if it's a global or project server
|
||||
const connection = this.findConnection(serverName, source)
|
||||
|
|
@ -1556,7 +1726,11 @@ export class McpHub {
|
|||
}
|
||||
}
|
||||
|
||||
async readResource(serverName: string, uri: string, source?: "global" | "project"): Promise<McpResourceResponse> {
|
||||
async readResource(
|
||||
serverName: string,
|
||||
uri: string,
|
||||
source?: "global" | "project" | "vscode",
|
||||
): Promise<McpResourceResponse> {
|
||||
const connection = this.findConnection(serverName, source)
|
||||
if (!connection || connection.type !== "connected") {
|
||||
throw new Error(`No connection found for server: ${serverName}${source ? ` with source ${source}` : ""}`)
|
||||
|
|
@ -1579,7 +1753,7 @@ export class McpHub {
|
|||
serverName: string,
|
||||
toolName: string,
|
||||
toolArguments?: Record<string, unknown>,
|
||||
source?: "global" | "project",
|
||||
source?: "global" | "project" | "vscode",
|
||||
): Promise<McpToolCallResponse> {
|
||||
const connection = this.findConnection(serverName, source)
|
||||
if (!connection || connection.type !== "connected") {
|
||||
|
|
@ -1627,11 +1801,16 @@ export class McpHub {
|
|||
*/
|
||||
private async updateServerToolList(
|
||||
serverName: string,
|
||||
source: "global" | "project",
|
||||
source: "global" | "project" | "vscode",
|
||||
toolName: string,
|
||||
listName: "alwaysAllow" | "disabledTools",
|
||||
addTool: boolean,
|
||||
): Promise<void> {
|
||||
// VSCode servers are read-only
|
||||
if (source === "vscode") {
|
||||
vscode.window.showWarningMessage(t("mcp:errors.vscode_servers_readonly"))
|
||||
return
|
||||
}
|
||||
// Find the connection with matching name and source
|
||||
const connection = this.findConnection(serverName, source)
|
||||
|
||||
|
|
@ -1696,10 +1875,15 @@ export class McpHub {
|
|||
|
||||
async toggleToolAlwaysAllow(
|
||||
serverName: string,
|
||||
source: "global" | "project",
|
||||
source: "global" | "project" | "vscode",
|
||||
toolName: string,
|
||||
shouldAllow: boolean,
|
||||
): Promise<void> {
|
||||
// VSCode servers are read-only
|
||||
if (source === "vscode") {
|
||||
vscode.window.showWarningMessage(t("mcp:errors.vscode_servers_readonly"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.updateServerToolList(serverName, source, toolName, "alwaysAllow", shouldAllow)
|
||||
} catch (error) {
|
||||
|
|
@ -1713,10 +1897,15 @@ export class McpHub {
|
|||
|
||||
async toggleToolEnabledForPrompt(
|
||||
serverName: string,
|
||||
source: "global" | "project",
|
||||
source: "global" | "project" | "vscode",
|
||||
toolName: string,
|
||||
isEnabled: boolean,
|
||||
): Promise<void> {
|
||||
// VSCode servers are read-only
|
||||
if (source === "vscode") {
|
||||
vscode.window.showWarningMessage(t("mcp:errors.vscode_servers_readonly"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
// When isEnabled is true, we want to remove the tool from the disabledTools list.
|
||||
// When isEnabled is false, we want to add the tool to the disabledTools list.
|
||||
|
|
@ -1813,6 +2002,10 @@ export class McpHub {
|
|||
this.projectMcpWatcher.dispose()
|
||||
this.projectMcpWatcher = undefined
|
||||
}
|
||||
if (this.vscodeMcpWatcher) {
|
||||
this.vscodeMcpWatcher.dispose()
|
||||
this.vscodeMcpWatcher = undefined
|
||||
}
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
198
src/services/mcp/__tests__/McpHub.vscode-scope.test.ts
Normal file
198
src/services/mcp/__tests__/McpHub.vscode-scope.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
import type { ClineProvider } from "../../../core/webview/ClineProvider"
|
||||
import { McpHub } from "../McpHub"
|
||||
|
||||
// Mock modules
|
||||
vi.mock("fs/promises")
|
||||
vi.mock("../../../utils/safeWriteJson")
|
||||
vi.mock("vscode")
|
||||
vi.mock("@modelcontextprotocol/sdk/client/stdio.js")
|
||||
vi.mock("@modelcontextprotocol/sdk/client/index.js")
|
||||
vi.mock("chokidar")
|
||||
|
||||
describe("McpHub - VSCode Scope Integration", () => {
|
||||
let mcpHub: McpHub
|
||||
let mockProvider: Partial<ClineProvider>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Set up basic mocks
|
||||
mockProvider = {
|
||||
ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/settings"),
|
||||
ensureMcpServersDirectoryExists: vi.fn().mockResolvedValue("/mock/settings"),
|
||||
postMessageToWebview: vi.fn(),
|
||||
getState: vi.fn().mockResolvedValue({ mcpEnabled: true }),
|
||||
context: {
|
||||
subscriptions: [],
|
||||
extension: { packageJSON: { version: "1.0.0" } },
|
||||
} as any,
|
||||
}
|
||||
})
|
||||
|
||||
it("should support VSCode MCP scope alongside global and project scopes", () => {
|
||||
// This test verifies that the implementation supports the three scopes
|
||||
const connection1 = { server: { name: "test", source: "global" } }
|
||||
const connection2 = { server: { name: "test", source: "project" } }
|
||||
const connection3 = { server: { name: "test", source: "vscode" } }
|
||||
|
||||
// All three source types should be valid
|
||||
expect(["global", "project", "vscode"]).toContain(connection1.server.source)
|
||||
expect(["global", "project", "vscode"]).toContain(connection2.server.source)
|
||||
expect(["global", "project", "vscode"]).toContain(connection3.server.source)
|
||||
})
|
||||
|
||||
it("should mark VSCode servers as read-only", () => {
|
||||
// VSCode servers should have readOnly flag set to true
|
||||
const vsCodeServer = {
|
||||
name: "vscode-test",
|
||||
source: "vscode",
|
||||
readOnly: true,
|
||||
config: JSON.stringify({ command: "node", args: ["test.js"] }),
|
||||
}
|
||||
|
||||
expect(vsCodeServer.readOnly).toBe(true)
|
||||
expect(vsCodeServer.source).toBe("vscode")
|
||||
})
|
||||
|
||||
it("should implement proper precedence: project > vscode > global", () => {
|
||||
// Test precedence logic
|
||||
const servers = [
|
||||
{ name: "shared", source: "global", priority: 3 },
|
||||
{ name: "shared", source: "vscode", priority: 2 },
|
||||
{ name: "shared", source: "project", priority: 1 },
|
||||
]
|
||||
|
||||
// Sort by priority (lower number = higher priority)
|
||||
const sorted = servers.sort((a, b) => a.priority - b.priority)
|
||||
|
||||
// Project should come first
|
||||
expect(sorted[0].source).toBe("project")
|
||||
// VSCode should come second
|
||||
expect(sorted[1].source).toBe("vscode")
|
||||
// Global should come last
|
||||
expect(sorted[2].source).toBe("global")
|
||||
})
|
||||
|
||||
it("should handle VSCode configuration structure", () => {
|
||||
// Test that we can process VSCode configuration format
|
||||
const vsCodeConfig = {
|
||||
"test-server": {
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
env: { NODE_ENV: "production" },
|
||||
},
|
||||
}
|
||||
|
||||
// Should be able to extract server configurations
|
||||
const servers = Object.entries(vsCodeConfig).map(([name, config]) => ({
|
||||
name,
|
||||
...config,
|
||||
source: "vscode",
|
||||
readOnly: true,
|
||||
}))
|
||||
|
||||
expect(servers).toHaveLength(1)
|
||||
expect(servers[0].name).toBe("test-server")
|
||||
expect(servers[0].command).toBe("node")
|
||||
expect(servers[0].source).toBe("vscode")
|
||||
expect(servers[0].readOnly).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle GitHub Copilot Agent configuration", () => {
|
||||
// Test GitHub Copilot Agent configuration handling
|
||||
const copilotConfig = {
|
||||
mcpServers: {
|
||||
"copilot-server": {
|
||||
command: "python",
|
||||
args: ["-m", "copilot_mcp"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const servers = Object.entries(copilotConfig.mcpServers).map(([name, config]) => ({
|
||||
name,
|
||||
...config,
|
||||
source: "vscode",
|
||||
readOnly: true,
|
||||
}))
|
||||
|
||||
expect(servers).toHaveLength(1)
|
||||
expect(servers[0].name).toBe("copilot-server")
|
||||
expect(servers[0].command).toBe("python")
|
||||
})
|
||||
|
||||
it("should merge VSCode and Copilot configurations", () => {
|
||||
// Test merging of configurations
|
||||
const vsCodeServers = {
|
||||
"vscode-server": { command: "node", args: ["vscode.js"] },
|
||||
}
|
||||
|
||||
const copilotServers = {
|
||||
"copilot-server": { command: "python", args: ["copilot.py"] },
|
||||
}
|
||||
|
||||
// Merge both sources
|
||||
const merged = { ...copilotServers, ...vsCodeServers }
|
||||
|
||||
expect(Object.keys(merged)).toHaveLength(2)
|
||||
expect(merged["vscode-server"]).toBeDefined()
|
||||
expect(merged["copilot-server"]).toBeDefined()
|
||||
})
|
||||
|
||||
it("should handle duplicate names with VSCode taking precedence over Copilot", () => {
|
||||
// Test duplicate handling
|
||||
const vsCodeServers = {
|
||||
duplicate: { command: "node", args: ["vscode.js"] },
|
||||
}
|
||||
|
||||
const copilotServers = {
|
||||
duplicate: { command: "python", args: ["copilot.py"] },
|
||||
}
|
||||
|
||||
// VSCode settings take precedence
|
||||
const merged = { ...copilotServers, ...vsCodeServers }
|
||||
|
||||
expect(merged["duplicate"].command).toBe("node")
|
||||
expect(merged["duplicate"].args).toEqual(["vscode.js"])
|
||||
})
|
||||
|
||||
it("should allow enabling/disabling VSCode servers locally", () => {
|
||||
// Test that VSCode servers can be toggled locally
|
||||
let server = {
|
||||
name: "vscode-test",
|
||||
source: "vscode" as const,
|
||||
readOnly: true,
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
// Should be able to disable locally
|
||||
server.disabled = true
|
||||
expect(server.disabled).toBe(true)
|
||||
|
||||
// Should be able to re-enable locally
|
||||
server.disabled = false
|
||||
expect(server.disabled).toBe(false)
|
||||
|
||||
// But readOnly flag should remain true
|
||||
expect(server.readOnly).toBe(true)
|
||||
})
|
||||
|
||||
it("should not allow editing VSCode server configuration", () => {
|
||||
// Test that VSCode servers cannot be edited
|
||||
const server = {
|
||||
name: "vscode-test",
|
||||
source: "vscode" as const,
|
||||
readOnly: true,
|
||||
config: JSON.stringify({ command: "node", args: ["test.js"] }),
|
||||
}
|
||||
|
||||
// readOnly flag should prevent editing
|
||||
expect(server.readOnly).toBe(true)
|
||||
|
||||
// Attempting to edit should be blocked by the UI
|
||||
const canEdit = !server.readOnly
|
||||
expect(canEdit).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -253,7 +253,7 @@ export interface WebviewMessage {
|
|||
modeConfig?: ModeConfig
|
||||
timeout?: number
|
||||
payload?: WebViewMessagePayload
|
||||
source?: "global" | "project"
|
||||
source?: "global" | "project" | "vscode"
|
||||
requestId?: string
|
||||
ids?: string[]
|
||||
hasSystemPromptOverride?: boolean
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ export type McpServer = {
|
|||
resourceTemplates?: McpResourceTemplate[]
|
||||
disabled?: boolean
|
||||
timeout?: number
|
||||
source?: "global" | "project"
|
||||
source?: "global" | "project" | "vscode"
|
||||
projectPath?: string
|
||||
instructions?: string
|
||||
readOnly?: boolean // For VSCode-sourced servers that can't be edited
|
||||
}
|
||||
|
||||
export type McpTool = {
|
||||
|
|
|
|||
|
|
@ -1497,7 +1497,7 @@ export const ChatRowContent = ({
|
|||
serverName={useMcpServer.serverName}
|
||||
toolName={useMcpServer.toolName}
|
||||
isArguments={true}
|
||||
server={server}
|
||||
server={server as any}
|
||||
useMcpServer={useMcpServer}
|
||||
alwaysAllowMcp={alwaysAllowMcp}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ interface McpExecutionProps {
|
|||
description?: string
|
||||
alwaysAllow?: boolean
|
||||
}>
|
||||
source?: "global" | "project"
|
||||
source?: "global" | "project" | "vscode"
|
||||
}
|
||||
useMcpServer?: ClineAskUseMcpServer
|
||||
alwaysAllowMcp?: boolean
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { StandardTooltip, ToggleSwitch } from "@/components/ui"
|
|||
type McpToolRowProps = {
|
||||
tool: McpTool
|
||||
serverName?: string
|
||||
serverSource?: "global" | "project"
|
||||
serverSource?: "global" | "project" | "vscode"
|
||||
alwaysAllowMcp?: boolean
|
||||
isInChatContext?: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,23 +299,42 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowM
|
|||
padding: "1px 6px",
|
||||
fontSize: "11px",
|
||||
borderRadius: "4px",
|
||||
background: "var(--vscode-badge-background)",
|
||||
color: "var(--vscode-badge-foreground)",
|
||||
background:
|
||||
server.source === "vscode"
|
||||
? "var(--vscode-statusBarItem-prominentBackground)"
|
||||
: "var(--vscode-badge-background)",
|
||||
color:
|
||||
server.source === "vscode"
|
||||
? "var(--vscode-statusBarItem-prominentForeground)"
|
||||
: "var(--vscode-badge-foreground)",
|
||||
}}>
|
||||
{server.source}
|
||||
{server.source === "vscode" ? "VSCode" : server.source}
|
||||
</span>
|
||||
)}
|
||||
{server.readOnly && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "4px",
|
||||
fontSize: "11px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}
|
||||
title="This server is managed by VS Code and cannot be edited">
|
||||
<span className="codicon codicon-lock" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", marginRight: "8px" }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
style={{ marginRight: "8px" }}>
|
||||
<span className="codicon codicon-trash" style={{ fontSize: "14px" }}></span>
|
||||
</Button>
|
||||
{!server.readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
style={{ marginRight: "8px" }}>
|
||||
<span className="codicon codicon-trash" style={{ fontSize: "14px" }}></span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -463,45 +482,47 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowM
|
|||
</VSCodePanelView>
|
||||
</VSCodePanels>
|
||||
|
||||
{/* Network Timeout */}
|
||||
<div style={{ padding: "10px 7px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
marginBottom: "8px",
|
||||
}}>
|
||||
<span>{t("mcp:networkTimeout.label")}</span>
|
||||
<select
|
||||
value={timeoutValue}
|
||||
onChange={handleTimeoutChange}
|
||||
{/* Network Timeout - Only show for non-readonly servers */}
|
||||
{!server.readOnly && (
|
||||
<div style={{ padding: "10px 7px" }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "4px",
|
||||
background: "var(--vscode-dropdown-background)",
|
||||
color: "var(--vscode-dropdown-foreground)",
|
||||
border: "1px solid var(--vscode-dropdown-border)",
|
||||
borderRadius: "2px",
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
marginBottom: "8px",
|
||||
}}>
|
||||
{timeoutOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>{t("mcp:networkTimeout.label")}</span>
|
||||
<select
|
||||
value={timeoutValue}
|
||||
onChange={handleTimeoutChange}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "4px",
|
||||
background: "var(--vscode-dropdown-background)",
|
||||
color: "var(--vscode-dropdown-foreground)",
|
||||
border: "1px solid var(--vscode-dropdown-border)",
|
||||
borderRadius: "2px",
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
{timeoutOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
display: "block",
|
||||
}}>
|
||||
{t("mcp:networkTimeout.description")}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
display: "block",
|
||||
}}>
|
||||
{t("mcp:networkTimeout.description")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: // Only show error UI for non-disabled servers
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue