diff --git a/assets/docs/demo.gif b/assets/docs/demo.gif new file mode 100644 index 0000000000..c45e64bc31 --- /dev/null +++ b/assets/docs/demo.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a27ab29e2b5cf8ae65efd35222d456de4b9b1956b159705f9ead3d19426fabae +size 7456839 diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index b6c7b54779..03f008ce2b 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -176,6 +176,35 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt visibleProvider.postMessageToWebview({ type: "acceptInput" }) }, + reloadAllMcpServers: async () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + if (!visibleProvider) { + return + } + try { + await visibleProvider.getMcpHub()?.restartAllMcpServers() + } catch (error) { + outputChannel.appendLine(`Failed to reload all MCP servers: ${error}`) + vscode.window.showErrorMessage(`Failed to reload all MCP servers: ${error}`) + } + }, + toggleAllMcpServersDisabled: async () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + if (!visibleProvider) { + return + } + try { + const mcpHub = visibleProvider.getMcpHub() + if (mcpHub) { + const allServers = mcpHub.getAllServers() + const anyEnabled = allServers.some((server) => !server.disabled) + await mcpHub.toggleAllServersDisabled(anyEnabled) + } + } catch (error) { + outputChannel.appendLine(`Failed to toggle all MCP servers: ${error}`) + vscode.window.showErrorMessage(`Failed to toggle all MCP servers: ${error}`) + } + }, }) export const openClineInNewTab = async ({ context, outputChannel }: Omit) => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 41232c8680..771c5530f0 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1267,5 +1267,16 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We await provider.postStateToWebview() break } + case "executeVSCodeCommand": { + if (message.command) { + try { + await vscode.commands.executeCommand(message.command) + } catch (error) { + provider.log(`Failed to execute VS Code command ${message.command}: ${error}`) + vscode.window.showErrorMessage(`Failed to execute command: ${message.command}`) + } + } + break + } } } diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 3538be7062..57e58a4e02 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -75,6 +75,8 @@ export const commandIds = [ "focusInput", "acceptInput", + "reloadAllMcpServers", + "toggleAllMcpServersDisabled", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index e83f7afb9e..2135319fce 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -1306,4 +1306,52 @@ export class McpHub { } this.disposables.forEach((d) => d.dispose()) } + + /** + * Enables or disables all global MCP servers at once. + * When activated, the configuration is reloaded. + * @param disabled true = disable all, false = enable all + */ + public async toggleAllServersDisabled(disabled: boolean): Promise { + // Collect all global server names + const allServers = this.getAllServers() + + // Set the Disabled flag for all servers + for (const server of allServers) { + await this.updateServerConfig(server.name, { disabled }, server.source) + const conn = this.findConnection(server.name, server.source) + if (conn) { + conn.server.disabled = disabled + } + } + + // If activated, reload configuration + if (!disabled) { + // Re-initialize all servers, both global and project + await this.initializeMcpServers("global") + await this.initializeMcpServers("project") + } + + await this.notifyWebviewOfServerChanges() + } + + /** + * Restarts all currently active MCP servers. + * This will trigger a popup for each server being restarted. + */ + public async restartAllMcpServers(): Promise { + const allServers = this.getAllServers() // Get all servers, regardless of disabled state + const restartPromises = allServers.map(async (server) => { + // Only restart if not disabled + if (!server.disabled) { + try { + await this.restartConnection(server.name, server.source) + } catch (error) { + this.showErrorMessage(`Failed to restart MCP server ${server.name}`, error) + } + } + }) + await Promise.all(restartPromises) + await this.notifyWebviewOfServerChanges() + } } diff --git a/src/services/mcp/__tests__/McpHub.test.ts b/src/services/mcp/__tests__/McpHub.test.ts index 182802e7df..e616869461 100644 --- a/src/services/mcp/__tests__/McpHub.test.ts +++ b/src/services/mcp/__tests__/McpHub.test.ts @@ -4,7 +4,9 @@ import type { ExtensionContext, Uri } from "vscode" import { ServerConfigSchema } from "../McpHub" const fs = require("fs/promises") -const { McpHub } = require("../McpHub") +const { McpHub } = jest.requireActual("../McpHub") // Use requireActual to get the real module + +let originalConsoleError: typeof console.error = console.error // Store original console methods globally jest.mock("vscode", () => ({ workspace: { @@ -30,17 +32,39 @@ jest.mock("vscode", () => ({ jest.mock("fs/promises") jest.mock("../../../core/webview/ClineProvider") +// Mock the McpHub module itself +jest.mock("../McpHub", () => { + const originalModule = jest.requireActual("../McpHub") + return { + __esModule: true, + ...originalModule, + McpHub: jest.fn().mockImplementation((provider) => { + const instance = new originalModule.McpHub(provider) + // Spy on private methods + jest.spyOn(instance, "updateServerConfig" as any).mockResolvedValue(undefined) + jest.spyOn(instance, "findConnection" as any).mockReturnValue({ server: { disabled: false } } as any) + jest.spyOn(instance, "initializeMcpServers" as any).mockResolvedValue(undefined) + jest.spyOn(instance, "notifyWebviewOfServerChanges" as any).mockResolvedValue(undefined) + jest.spyOn(instance, "restartConnection" as any).mockResolvedValue(undefined) + jest.spyOn(instance, "showErrorMessage" as any).mockImplementation(jest.fn()) + jest.spyOn(instance, "getAllServers" as any).mockReturnValue([ + { name: "server1", source: "global", disabled: false, config: "{}", status: "connected" }, + { name: "server2", source: "project", disabled: false, config: "{}", status: "connected" }, + ]) + return instance + }), + } +}) + describe("McpHub", () => { let mcpHub: McpHubType let mockProvider: Partial - // Store original console methods - const originalConsoleError = console.error - beforeEach(() => { jest.clearAllMocks() // Mock console.error to suppress error messages during tests + originalConsoleError = console.error // Store original before mocking console.error = jest.fn() const mockUri: Uri = { @@ -317,6 +341,130 @@ describe("McpHub", () => { }) }) + describe("toggleAllServersDisabled", () => { + it("should disable all servers when passed true", async () => { + const mockConnections: McpConnection[] = [ + { + server: { + name: "server1", + config: "{}", + status: "connected", + disabled: false, + }, + client: {} as any, + transport: {} as any, + }, + { + server: { + name: "server2", + config: "{}", + status: "connected", + disabled: false, + }, + client: {} as any, + transport: {} as any, + }, + ] + mcpHub.connections = mockConnections + + // Mock fs.readFile to return a config with both servers enabled + ;(fs.readFile as jest.Mock).mockResolvedValueOnce( + JSON.stringify({ + mcpServers: { + server1: { disabled: false }, + server2: { disabled: false }, + }, + }), + ) + + await mcpHub.toggleAllServersDisabled(true) + + // Verify that both servers are now disabled in the connections + expect(mcpHub.connections[0].server.disabled).toBe(true) + expect(mcpHub.connections[1].server.disabled).toBe(true) + + // Mock fs.readFile and fs.writeFile to track config changes + const writeCalls: any[] = [] + ;(fs.readFile as jest.Mock).mockResolvedValue( + JSON.stringify({ mcpServers: { server1: { disabled: false }, server2: { disabled: false } } }), + ) + ;(fs.writeFile as jest.Mock).mockImplementation(async (path, data) => { + writeCalls.push(JSON.parse(data)) + }) + + await mcpHub.toggleAllServersDisabled(true) + + // Verify that both servers are now disabled in the connections + expect(mcpHub.connections[0].server.disabled).toBe(true) + expect(mcpHub.connections[1].server.disabled).toBe(true) + + // Verify that fs.writeFile was called for each server + expect(writeCalls.length).toBe(2) + expect(writeCalls[0].mcpServers.server1.disabled).toBe(true) + expect(writeCalls[1].mcpServers.server2.disabled).toBe(true) + + // Verify that postMessageToWebview was called + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "mcpServers", + }), + ) + }) + + it("should enable all servers when passed false", async () => { + const mockConnections: McpConnection[] = [ + { + server: { + name: "server1", + config: "{}", + status: "connected", + disabled: true, + }, + client: {} as any, + transport: {} as any, + }, + { + server: { + name: "server2", + config: "{}", + status: "connected", + disabled: true, + }, + client: {} as any, + transport: {} as any, + }, + ] + mcpHub.connections = mockConnections + + // Mock fs.readFile and fs.writeFile to track config changes + const writeCalls: any[] = [] + ;(fs.readFile as jest.Mock).mockResolvedValue( + JSON.stringify({ mcpServers: { server1: { disabled: true }, server2: { disabled: true } } }), + ) + ;(fs.writeFile as jest.Mock).mockImplementation(async (path, data) => { + writeCalls.push(JSON.parse(data)) + }) + + await mcpHub.toggleAllServersDisabled(false) + + // Verify that both servers are now enabled in the connections + expect(mcpHub.connections[0].server.disabled).toBe(false) + expect(mcpHub.connections[1].server.disabled).toBe(false) + + // Verify that fs.writeFile was called for each server + expect(writeCalls.length).toBe(2) + expect(writeCalls[0].mcpServers.server1.disabled).toBe(false) + expect(writeCalls[1].mcpServers.server2.disabled).toBe(false) + + // Verify that postMessageToWebview was called + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "mcpServers", + }), + ) + }) + }) + describe("callTool", () => { it("should execute tool successfully", async () => { // Mock the connection with a minimal client implementation @@ -560,4 +708,129 @@ describe("McpHub", () => { }) }) }) + + describe("restartAllMcpServers", () => { + let mcpHub: McpHubType + let mockProvider: Partial + + beforeEach(() => { + jest.clearAllMocks() + // Mock console.error to suppress error messages during tests + originalConsoleError = console.error // Store original before mocking + console.error = jest.fn() + + const mockUri: Uri = { + scheme: "file", + authority: "", + path: "/test/path", + query: "", + fragment: "", + fsPath: "/test/path", + with: jest.fn(), + toJSON: jest.fn(), + } + + mockProvider = { + ensureSettingsDirectoryExists: jest.fn().mockResolvedValue("/mock/settings/path"), + ensureMcpServersDirectoryExists: jest.fn().mockResolvedValue("/mock/settings/path"), + postMessageToWebview: jest.fn(), + context: { + subscriptions: [], + workspaceState: {} as any, + globalState: {} as any, + secrets: {} as any, + extensionUri: mockUri, + extensionPath: "/test/path", + storagePath: "/test/storage", + globalStoragePath: "/test/global-storage", + environmentVariableCollection: {} as any, + extension: { + id: "test-extension", + extensionUri: mockUri, + extensionPath: "/test/path", + extensionKind: 1, + isActive: true, + packageJSON: { + version: "1.0.0", + }, + activate: jest.fn(), + exports: undefined, + } as any, + asAbsolutePath: (path: string) => path, + storageUri: mockUri, + globalStorageUri: mockUri, + logUri: mockUri, + extensionMode: 1, + logPath: "/test/path", + languageModelAccessInformation: {} as any, + } as ExtensionContext, + } + + // Mock fs.readFile for initial settings + ;(fs.readFile as jest.Mock).mockResolvedValue( + JSON.stringify({ + mcpServers: { + "test-server": { + type: "stdio", + command: "node", + args: ["test.js"], + alwaysAllow: ["allowed-tool"], + }, + }, + }), + ) + + mcpHub = new McpHub(mockProvider as ClineProvider) + jest.spyOn(mcpHub as any, "showErrorMessage").mockImplementation(jest.fn()) + + // Mock internal methods + jest.spyOn(mcpHub, "getAllServers" as any).mockReturnValue([ + { name: "server1", source: "global", disabled: false }, + { name: "server2", source: "project", disabled: true }, // Disabled server + { name: "server3", source: "global", disabled: false }, + ]) + jest.spyOn(mcpHub, "restartConnection" as any).mockResolvedValue(undefined) + jest.spyOn(mcpHub as any, "notifyWebviewOfServerChanges").mockResolvedValue(undefined) + }) + + afterEach(() => { + // Restore original console methods + console.error = originalConsoleError + jest.restoreAllMocks() // Clean up spies + }) + + it("should restart only active servers", async () => { + await mcpHub.restartAllMcpServers() + + expect(mcpHub.getAllServers).toHaveBeenCalled() + expect(mcpHub.restartConnection).toHaveBeenCalledTimes(2) // Only server1 and server3 should be restarted + expect(mcpHub.restartConnection).toHaveBeenCalledWith("server1", "global") + expect(mcpHub.restartConnection).not.toHaveBeenCalledWith("server2", "project") + expect(mcpHub.restartConnection).toHaveBeenCalledWith("server3", "global") + expect((mcpHub as any).notifyWebviewOfServerChanges).toHaveBeenCalledTimes(1) + }) + + it("should call showErrorMessage if a restart fails", async () => { + jest.spyOn(mcpHub, "restartConnection" as any).mockRejectedValueOnce(new Error("Restart failed")) + + await mcpHub.restartAllMcpServers() + + expect(mcpHub.getAllServers).toHaveBeenCalled() + expect(mcpHub.restartConnection).toHaveBeenCalledTimes(2) // Only active servers are attempted to restart + expect((mcpHub as any).showErrorMessage).toHaveBeenCalledTimes(1) + expect((mcpHub as any).showErrorMessage).toHaveBeenCalledWith( + "Failed to restart MCP server server1", + expect.any(Error), + ) + expect((mcpHub as any).notifyWebviewOfServerChanges).toHaveBeenCalledTimes(1) + }) + + it("should call notifyWebviewOfServerChanges even if some restarts fail", async () => { + jest.spyOn(mcpHub, "restartConnection").mockRejectedValue(new Error("Restart failed")) + + await mcpHub.restartAllMcpServers() + + expect((mcpHub as any).notifyWebviewOfServerChanges).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index af9b637980..ddec319c38 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -135,6 +135,7 @@ export interface WebviewMessage { | "toggleApiConfigPin" | "setHistoryPreviewCollapsed" | "condenseTaskContextRequest" + | "executeVSCodeCommand" text?: string disabled?: boolean askResponse?: ClineAskResponse @@ -164,6 +165,7 @@ export interface WebviewMessage { hasSystemPromptOverride?: boolean terminalOperation?: "continue" | "abort" historyPreviewCollapsed?: boolean + command?: string } export const checkoutDiffPayloadSchema = z.object({ diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index 7b6a89799b..093904ce29 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -105,6 +105,29 @@ const McpView = ({ onDone }: McpViewProps) => { +
+ + +
+ {/* Server List */} {servers.length > 0 && (
diff --git a/webview-ui/src/i18n/locales/de/mcp.json b/webview-ui/src/i18n/locales/de/mcp.json index 1140d443ac..ababea79cd 100644 --- a/webview-ui/src/i18n/locales/de/mcp.json +++ b/webview-ui/src/i18n/locales/de/mcp.json @@ -53,5 +53,7 @@ "serverStatus": { "retrying": "Wiederhole...", "retryConnection": "Verbindung wiederholen" - } + }, + "reloadAllServers": "Alle MCP-Server neu laden", + "toggleAllServers": "Alle MCP-Server aktivieren/deaktivieren" } diff --git a/webview-ui/src/i18n/locales/en/mcp.json b/webview-ui/src/i18n/locales/en/mcp.json index c7d6f851ff..77fa994427 100644 --- a/webview-ui/src/i18n/locales/en/mcp.json +++ b/webview-ui/src/i18n/locales/en/mcp.json @@ -53,5 +53,7 @@ "serverStatus": { "retrying": "Retrying...", "retryConnection": "Retry Connection" - } + }, + "reloadAllServers": "Reload All MCP Servers", + "toggleAllServers": "Toggle All MCP Servers Enabled/Disabled" }