diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index a9a2e6a6b5..455c606017 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -844,7 +844,7 @@ export class CustomModesManager { private async importRulesFiles( importMode: ExportedModeConfig, rulesFiles: RuleFile[], - source: "global" | "project", + source: "global" | "project" | "vscode", ): Promise { // Determine base directory and rules folder path based on source let baseDir: string @@ -853,6 +853,10 @@ export class CustomModesManager { if (source === "global") { baseDir = getGlobalRooDirectory() rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`) + } else if (source === "vscode") { + // VSCode-sourced modes shouldn't have rules files imported + // They are read-only and managed by VS Code + return } else { const workspacePath = getWorkspacePath() baseDir = path.join(workspacePath, ".roo") @@ -919,12 +923,12 @@ export class CustomModesManager { /** * Imports modes from YAML content, including their associated rules files * @param yamlContent - The YAML content containing mode configurations - * @param source - Target level for import: "global" (all projects) or "project" (current workspace only) + * @param source - Target level for import: "global" (all projects), "project" (current workspace only), or "vscode" (VS Code managed) * @returns Success status with optional error message */ public async importModeWithRules( yamlContent: string, - source: "global" | "project" = "project", + source: "global" | "project" | "vscode" = "project", ): Promise { try { // Parse the YAML content with proper type validation @@ -953,6 +957,14 @@ export class CustomModesManager { } } + // VSCode source is not allowed for imports + if (source === "vscode") { + return { + success: false, + error: "Cannot import modes with VSCode source. VSCode-sourced modes are managed by VS Code configuration.", + } + } + // Process each mode in the import for (const importMode of importData.customModes) { const { rulesFiles, ...modeConfig } = importMode @@ -977,9 +989,10 @@ export class CustomModesManager { } // Import the mode configuration with the specified source + // Note: "vscode" source is already rejected above, so this will only be "global" or "project" await this.updateCustomMode(importMode.slug, { ...modeConfig, - source: source, // Use the provided source parameter + source: source as "global" | "project", // Safe cast since "vscode" is rejected above }) // Import rules files (this also handles cleanup of existing rules folders) diff --git a/src/i18n/locales/en/mcp.json b/src/i18n/locales/en/mcp.json index 0200e26d22..35d98b8d48 100644 --- a/src/i18n/locales/en/mcp.json +++ b/src/i18n/locales/en/mcp.json @@ -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...", diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index caca5ddb39..8f97a9ccfd 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -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 = 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 = new Map() + private vscodeMcpServers: Map = 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 { + private async handleConfigFileChange(filePath: string, source: "global" | "project" | "vscode"): Promise { 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 { + private async initializeMcpServers(source: "global" | "project" | "vscode"): Promise { 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 { + 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 { + 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, + ) + : {} + + // 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, + ) + : {} + + // 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 { + await this.initializeVSCodeMcpServers() + } + + // Watch VSCode MCP settings for changes + private async watchVSCodeMcpSettings(): Promise { + // 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, - 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, - source: "global" | "project" = "global", + source: "global" | "project" | "vscode" = "global", ): Promise { // 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 { + private async fetchToolsList(serverName: string, source?: "global" | "project" | "vscode"): Promise { 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 { + private async fetchResourcesList( + serverName: string, + source?: "global" | "project" | "vscode", + ): Promise { 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 { try { const connection = this.findConnection(serverName, source) @@ -993,7 +1098,7 @@ export class McpHub { } } - async deleteConnection(name: string, source?: "global" | "project"): Promise { + async deleteConnection(name: string, source?: "global" | "project" | "vscode"): Promise { // Clean up file watchers for this server this.removeFileWatchersForServer(name) @@ -1023,7 +1128,7 @@ export class McpHub { async updateServerConnections( newServers: Record, - source: "global" | "project" = "global", + source: "global" | "project" | "vscode" = "global", manageConnectingState: boolean = true, ): Promise { if (manageConnectingState) { @@ -1093,7 +1198,7 @@ export class McpHub { private setupFileWatcher( name: string, config: z.infer, - 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 { + async restartConnection(serverName: string, source?: "global" | "project" | "vscode"): Promise { 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 { + // 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, - source: "global" | "project" = "global", + source: "global" | "project" | "vscode" = "global", ): Promise { + // 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 { + // 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 { + public async deleteServer(serverName: string, source?: "global" | "project" | "vscode"): Promise { + // 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 { + async readResource( + serverName: string, + uri: string, + source?: "global" | "project" | "vscode", + ): Promise { 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, - source?: "global" | "project", + source?: "global" | "project" | "vscode", ): Promise { 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 { + // 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 { + // 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 { + // 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()) } } diff --git a/src/services/mcp/__tests__/McpHub.vscode-scope.test.ts b/src/services/mcp/__tests__/McpHub.vscode-scope.test.ts new file mode 100644 index 0000000000..ff216bb237 --- /dev/null +++ b/src/services/mcp/__tests__/McpHub.vscode-scope.test.ts @@ -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 + + 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) + }) +}) diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 93d0b9bc45..d9be44b341 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -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 diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts index ef1d51bad3..8d1533a0d9 100644 --- a/src/shared/mcp.ts +++ b/src/shared/mcp.ts @@ -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 = { diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 23ec50af37..25ffe648e5 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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} /> diff --git a/webview-ui/src/components/chat/McpExecution.tsx b/webview-ui/src/components/chat/McpExecution.tsx index a96f368a17..bee27a955a 100644 --- a/webview-ui/src/components/chat/McpExecution.tsx +++ b/webview-ui/src/components/chat/McpExecution.tsx @@ -24,7 +24,7 @@ interface McpExecutionProps { description?: string alwaysAllow?: boolean }> - source?: "global" | "project" + source?: "global" | "project" | "vscode" } useMcpServer?: ClineAskUseMcpServer alwaysAllowMcp?: boolean diff --git a/webview-ui/src/components/mcp/McpToolRow.tsx b/webview-ui/src/components/mcp/McpToolRow.tsx index aa57b18fd9..36df807300 100644 --- a/webview-ui/src/components/mcp/McpToolRow.tsx +++ b/webview-ui/src/components/mcp/McpToolRow.tsx @@ -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 } diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index 21ad1c2652..41eac1ed88 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -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} + + )} + {server.readOnly && ( + + )}
e.stopPropagation()}> - + {!server.readOnly && ( + + )}
+ )} ) : // Only show error UI for non-disabled servers