From 8a0062712b1b10c28c76e5f33de622f4c3411a76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:33:54 +0000 Subject: [PATCH 1/4] Initial plan From bcd2e0f4a945bce50b408addce3a4ba5dea9b565 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:48:58 +0000 Subject: [PATCH 2/4] Add automatic uninstall of duplicate manual MCP servers When the bundled MCP provider is used (VS Code >= 1.101.0 with MCP API support), automatically detect and uninstall any duplicate manual MCP installations that were previously set up via the CLI when the IDE didn't support bundled MCP. This prevents duplicate MCP servers from coexisting when an IDE adds support for VS Code's native MCP API. Co-authored-by: sergeibbb <83944326+sergeibbb@users.noreply.github.com> --- docs/telemetry-events.md | 11 ++++++++++ src/constants.telemetry.ts | 7 +++++++ src/env/node/gk/mcp/integration.ts | 33 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index 01c2d66ca83a9..4165dc42a2e4e 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -2813,6 +2813,17 @@ void } ``` +### mcp/uninstall/duplicate + +> Sent when a duplicate manual MCP installation is uninstalled + +```typescript +{ + 'app': string, + 'source': 'account' | 'subscription' | 'graph' | 'composer' | 'patchDetails' | 'settings' | 'timeline' | 'home' | 'ai' | 'ai:markdown-preview' | 'ai:markdown-editor' | 'ai:picker' | 'associateIssueWithBranch' | 'cloud-patches' | 'code-suggest' | 'commandPalette' | 'deeplink' | 'editor:hover' | 'feature-badge' | 'feature-gate' | 'gk-cli-integration' | 'gk-mcp-provider' | 'inspect' | 'inspect-overview' | 'integrations' | 'launchpad' | 'launchpad-indicator' | 'launchpad-view' | 'mcp' | 'mcp-welcome-message' | 'merge-target' | 'notification' | 'prompt' | 'quick-wizard' | 'rebaseEditor' | 'remoteProvider' | 'scm' | 'scm-input' | 'startWork' | 'trial-indicator' | 'view' | 'walkthrough' | 'whatsnew' | 'worktrees' +} +``` + ### openReviewMode > Sent when a PR review was started in the inspect overview diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index 1622d10ea6e0a..025dea7be6672 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -254,6 +254,8 @@ export interface TelemetryEvents extends WebviewShowAbortedEvents, WebviewShownE 'mcp/setup/failed': MCPSetupFailedEvent; /** Sent when GitKraken MCP registration fails */ 'mcp/registration/failed': MCPSetupFailedEvent; + /** Sent when a duplicate manual MCP installation is uninstalled */ + 'mcp/uninstall/duplicate': MCPUninstallDuplicateEvent; /** Sent when a PR review was started in the inspect overview */ openReviewMode: OpenReviewModeEvent; @@ -526,6 +528,11 @@ export interface MCPSetupFailedEvent { 'error.message'?: string; } +export interface MCPUninstallDuplicateEvent { + app: string; + source: Sources; +} + interface CloudIntegrationsConnectingEvent { 'integration.ids': string | undefined; } diff --git a/src/env/node/gk/mcp/integration.ts b/src/env/node/gk/mcp/integration.ts index a2458ee19770b..be250b599fe66 100644 --- a/src/env/node/gk/mcp/integration.ts +++ b/src/env/node/gk/mcp/integration.ts @@ -95,6 +95,9 @@ export class GkMcpProvider implements McpServerDefinitionProvider, Disposable { return undefined; } + // Clean up any duplicate manual installations before registering the bundled version + await this.uninstallManualMcpIfExists(appName, cliPath); + let output = await runCLICommand(['mcp', 'config', appName, '--source=gitlens', `--scheme=${env.uriScheme}`], { cwd: cliPath, }); @@ -120,6 +123,36 @@ export class GkMcpProvider implements McpServerDefinitionProvider, Disposable { return undefined; } + @debug() + private async uninstallManualMcpIfExists(appName: string, cliPath: string): Promise { + try { + // Check if a manual MCP installation exists by attempting to uninstall it + // The uninstall command will only succeed if there's an existing manual installation + const output = await runCLICommand( + ['mcp', 'uninstall', appName, '--source=gitlens', `--scheme=${env.uriScheme}`], + { + cwd: cliPath, + }, + ); + + // If uninstall succeeded, log and send telemetry + if (output.trim().length > 0) { + Logger.log(`Uninstalled duplicate manual MCP installation for ${appName}`); + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('mcp/uninstall/duplicate', { + app: appName, + source: 'gk-mcp-provider', + }); + } + } + } catch (ex) { + // If uninstall fails, it likely means no manual installation exists + // Log the error at debug level but don't fail the overall process + Logger.debug(`No manual MCP installation to uninstall for ${appName}: ${ex}`); + } + } + private onRegistrationCompleted(_cliVersion?: string | undefined) { if (!this.container.telemetry.enabled) return; From e380a8450e4d7bb789b2f430c84a189458548fa9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:28:18 +0000 Subject: [PATCH 3/4] Implement JSON-based duplicate MCP server cleanup Instead of using `gk mcp uninstall` command (which doesn't work properly), directly analyze and modify MCP configuration JSON files in user profile. The implementation: - Locates the IDE-specific settings.json file based on app name and platform - Reads and parses the file (handling JSON comments) - Identifies GitKraken MCP servers by name pattern, command path, and source flag - Removes duplicate manual installations - Saves the updated configuration This approach provides proper cleanup of duplicate manual MCP installations when the bundled MCP provider becomes available. Co-authored-by: sergeibbb <83944326+sergeibbb@users.noreply.github.com> --- src/env/node/gk/mcp/integration.ts | 201 +++++++++++++++++++++++++---- 1 file changed, 177 insertions(+), 24 deletions(-) diff --git a/src/env/node/gk/mcp/integration.ts b/src/env/node/gk/mcp/integration.ts index be250b599fe66..4b450c20c430f 100644 --- a/src/env/node/gk/mcp/integration.ts +++ b/src/env/node/gk/mcp/integration.ts @@ -1,5 +1,8 @@ +import { homedir } from 'os'; +import { join } from 'path'; +import { env as processEnv } from 'process'; import type { Event, McpServerDefinition, McpServerDefinitionProvider } from 'vscode'; -import { Disposable, env, EventEmitter, lm, McpStdioServerDefinition } from 'vscode'; +import { Disposable, env, EventEmitter, lm, McpStdioServerDefinition, Uri, workspace } from 'vscode'; import type { Container } from '../../../../container'; import type { StorageChangeEvent } from '../../../../system/-webview/storage'; import { getHostAppName } from '../../../../system/-webview/vscode'; @@ -7,6 +10,7 @@ import { debug, log } from '../../../../system/decorators/log'; import type { Deferrable } from '../../../../system/function/debounce'; import { debounce } from '../../../../system/function/debounce'; import { Logger } from '../../../../system/logger'; +import { getPlatform } from '../../platform'; import { runCLICommand, toMcpInstallProvider } from '../cli/utils'; const CLIProxyMCPConfigOutputs = { @@ -96,7 +100,7 @@ export class GkMcpProvider implements McpServerDefinitionProvider, Disposable { } // Clean up any duplicate manual installations before registering the bundled version - await this.uninstallManualMcpIfExists(appName, cliPath); + await this.removeDuplicateManualMcpConfigurations(appName); let output = await runCLICommand(['mcp', 'config', appName, '--source=gitlens', `--scheme=${env.uriScheme}`], { cwd: cliPath, @@ -124,35 +128,184 @@ export class GkMcpProvider implements McpServerDefinitionProvider, Disposable { } @debug() - private async uninstallManualMcpIfExists(appName: string, cliPath: string): Promise { + private async removeDuplicateManualMcpConfigurations(appName: string): Promise { try { - // Check if a manual MCP installation exists by attempting to uninstall it - // The uninstall command will only succeed if there's an existing manual installation - const output = await runCLICommand( - ['mcp', 'uninstall', appName, '--source=gitlens', `--scheme=${env.uriScheme}`], - { - cwd: cliPath, - }, - ); - - // If uninstall succeeded, log and send telemetry - if (output.trim().length > 0) { - Logger.log(`Uninstalled duplicate manual MCP installation for ${appName}`); - - if (this.container.telemetry.enabled) { - this.container.telemetry.sendEvent('mcp/uninstall/duplicate', { - app: appName, - source: 'gk-mcp-provider', - }); + const settingsPath = this.getUserSettingsPath(appName); + if (settingsPath == null) { + Logger.debug(`Unable to determine settings path for ${appName}`); + return; + } + + const settingsUri = Uri.file(settingsPath); + + // Check if settings file exists + try { + await workspace.fs.stat(settingsUri); + } catch { + // Settings file doesn't exist, nothing to clean up + Logger.debug(`Settings file does not exist: ${settingsPath}`); + return; + } + + // Read and parse settings file + const settingsBytes = await workspace.fs.readFile(settingsUri); + const settingsText = new TextDecoder().decode(settingsBytes); + + // Parse JSON with comments support (VS Code settings.json allows comments) + const settings = this.parseJsonWithComments(settingsText); + + // Check for MCP server configurations + const mcpServersKey = 'languageModels.chat.mcpServers'; + if (!settings[mcpServersKey] || typeof settings[mcpServersKey] !== 'object') { + Logger.debug('No MCP server configurations found'); + return; + } + + const mcpServers = settings[mcpServersKey] as Record; + let removedCount = 0; + const serversToRemove: string[] = []; + + // Look for GitKraken MCP servers that were manually installed + // These typically have names like "gitkraken" or "GitKraken" and contain + // the GK CLI executable path + for (const [serverName, serverConfig] of Object.entries(mcpServers)) { + if (this.isGitKrakenMcpServer(serverName, serverConfig)) { + serversToRemove.push(serverName); + Logger.log(`Found duplicate manual MCP configuration: ${serverName}`); } } + + // Remove the servers + for (const serverName of serversToRemove) { + mcpServers[serverName] = undefined; + removedCount++; + } + + if (removedCount === 0) { + Logger.debug('No duplicate manual MCP configurations found'); + return; + } + + // Save updated settings + const updatedSettingsText = JSON.stringify(settings, null, '\t'); + await workspace.fs.writeFile(settingsUri, new TextEncoder().encode(updatedSettingsText)); + + Logger.log(`Removed ${removedCount} duplicate manual MCP configuration(s) from ${settingsPath}`); + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('mcp/uninstall/duplicate', { + app: appName, + source: 'gk-mcp-provider', + }); + } } catch (ex) { - // If uninstall fails, it likely means no manual installation exists - // Log the error at debug level but don't fail the overall process - Logger.debug(`No manual MCP installation to uninstall for ${appName}: ${ex}`); + // Log error but don't fail the overall process + Logger.error(`Error removing duplicate MCP configurations: ${ex}`); + } + } + + private getUserSettingsPath(appName: string): string | null { + const platform = getPlatform(); + const home = homedir(); + const appData = processEnv.APPDATA || join(home, 'AppData', 'Roaming'); + + switch (appName) { + case 'vscode': + switch (platform) { + case 'windows': + return join(appData, 'Code', 'User', 'settings.json'); + case 'macOS': + return join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'); + default: // linux + return join(home, '.config', 'Code', 'User', 'settings.json'); + } + case 'vscode-insiders': + switch (platform) { + case 'windows': + return join(appData, 'Code - Insiders', 'User', 'settings.json'); + case 'macOS': + return join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'settings.json'); + default: // linux + return join(home, '.config', 'Code - Insiders', 'User', 'settings.json'); + } + case 'vscode-exploration': + switch (platform) { + case 'windows': + return join(appData, 'Code - Exploration', 'User', 'settings.json'); + case 'macOS': + return join(home, 'Library', 'Application Support', 'Code - Exploration', 'User', 'settings.json'); + default: // linux + return join(home, '.config', 'Code - Exploration', 'User', 'settings.json'); + } + case 'cursor': + switch (platform) { + case 'windows': + return join(appData, 'Cursor', 'User', 'settings.json'); + case 'macOS': + return join(home, 'Library', 'Application Support', 'Cursor', 'User', 'settings.json'); + default: // linux + return join(home, '.config', 'Cursor', 'User', 'settings.json'); + } + case 'windsurf': + switch (platform) { + case 'windows': + return join(appData, 'Windsurf', 'User', 'settings.json'); + case 'macOS': + return join(home, 'Library', 'Application Support', 'Windsurf', 'User', 'settings.json'); + default: // linux + return join(home, '.config', 'Windsurf', 'User', 'settings.json'); + } + case 'codium': + switch (platform) { + case 'windows': + return join(appData, 'VSCodium', 'User', 'settings.json'); + case 'macOS': + return join(home, 'Library', 'Application Support', 'VSCodium', 'User', 'settings.json'); + default: // linux + return join(home, '.config', 'VSCodium', 'User', 'settings.json'); + } + default: + return null; } } + private parseJsonWithComments(text: string): Record { + // Simple JSON comment remover - removes // and /* */ comments + // This is a simplified version; VS Code uses jsonc-parser for full support + const withoutComments = text + .replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments + .replace(/\/\/.*/g, ''); // Remove // comments + + return JSON.parse(withoutComments) as Record; + } + + private isGitKrakenMcpServer(serverName: string, serverConfig: unknown): boolean { + // Check if this is a GitKraken MCP server by looking for: + // 1. Server name matches GitKraken variants + // 2. Command contains 'gk' executable + // 3. Args contain '--source=gitlens' or scheme parameter + + const nameMatches = /^git[_-]?kraken$/i.test(serverName); + + if (typeof serverConfig !== 'object' || serverConfig == null) { + return false; + } + + const config = serverConfig as Record; + const command = typeof config.command === 'string' ? config.command : ''; + const args = Array.isArray(config.args) ? config.args : []; + + // Check if command contains gk executable + const commandMatches = command.includes('/gk') || command.includes('\\gk') || command.endsWith('gk.exe'); + + // Check if args contain source=gitlens + const argsMatch = args.some((arg: unknown) => + typeof arg === 'string' && arg.includes('--source=gitlens') + ); + + return nameMatches && commandMatches && argsMatch; + } + private onRegistrationCompleted(_cliVersion?: string | undefined) { if (!this.container.telemetry.enabled) return; From 5d270289c1fbc3ba094e99a5814404c51c5ce8d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:55:03 +0000 Subject: [PATCH 4/4] Use globalStorageUri to locate user settings folder Instead of manually constructing platform-specific paths, use VS Code's globalStorageUri and navigate up 2 levels to reach the User folder. Benefits: - More reliable - VS Code tells us where it stores files - Simpler code - removed ~70 lines of platform-specific logic - Handles custom installation paths automatically - Works for all IDEs that support VS Code extensions globalStorageUri points to: .../[AppName]/User/globalStorage/eamodio.gitlens Going up 2 levels gets: .../[AppName]/User/ Then append settings.json to get the settings file path. Co-authored-by: sergeibbb <83944326+sergeibbb@users.noreply.github.com> --- src/env/node/gk/mcp/integration.ts | 92 ++++-------------------------- 1 file changed, 11 insertions(+), 81 deletions(-) diff --git a/src/env/node/gk/mcp/integration.ts b/src/env/node/gk/mcp/integration.ts index 4b450c20c430f..c28503bbd9694 100644 --- a/src/env/node/gk/mcp/integration.ts +++ b/src/env/node/gk/mcp/integration.ts @@ -1,6 +1,3 @@ -import { homedir } from 'os'; -import { join } from 'path'; -import { env as processEnv } from 'process'; import type { Event, McpServerDefinition, McpServerDefinitionProvider } from 'vscode'; import { Disposable, env, EventEmitter, lm, McpStdioServerDefinition, Uri, workspace } from 'vscode'; import type { Container } from '../../../../container'; @@ -10,7 +7,6 @@ import { debug, log } from '../../../../system/decorators/log'; import type { Deferrable } from '../../../../system/function/debounce'; import { debounce } from '../../../../system/function/debounce'; import { Logger } from '../../../../system/logger'; -import { getPlatform } from '../../platform'; import { runCLICommand, toMcpInstallProvider } from '../cli/utils'; const CLIProxyMCPConfigOutputs = { @@ -100,7 +96,7 @@ export class GkMcpProvider implements McpServerDefinitionProvider, Disposable { } // Clean up any duplicate manual installations before registering the bundled version - await this.removeDuplicateManualMcpConfigurations(appName); + await this.removeDuplicateManualMcpConfigurations(); let output = await runCLICommand(['mcp', 'config', appName, '--source=gitlens', `--scheme=${env.uriScheme}`], { cwd: cliPath, @@ -128,22 +124,21 @@ export class GkMcpProvider implements McpServerDefinitionProvider, Disposable { } @debug() - private async removeDuplicateManualMcpConfigurations(appName: string): Promise { + private async removeDuplicateManualMcpConfigurations(): Promise { try { - const settingsPath = this.getUserSettingsPath(appName); - if (settingsPath == null) { - Logger.debug(`Unable to determine settings path for ${appName}`); - return; - } - - const settingsUri = Uri.file(settingsPath); + // Use globalStorageUri to locate the User folder where settings.json is stored + // globalStorageUri points to: .../[AppName]/User/globalStorage/eamodio.gitlens + // Going up 2 levels gets us to: .../[AppName]/User/ + const globalStorageUri = this.container.context.globalStorageUri; + const userFolderUri = Uri.joinPath(globalStorageUri, '..', '..'); + const settingsUri = Uri.joinPath(userFolderUri, 'settings.json'); // Check if settings file exists try { await workspace.fs.stat(settingsUri); } catch { // Settings file doesn't exist, nothing to clean up - Logger.debug(`Settings file does not exist: ${settingsPath}`); + Logger.debug(`Settings file does not exist: ${settingsUri.fsPath}`); return; } @@ -190,11 +185,11 @@ export class GkMcpProvider implements McpServerDefinitionProvider, Disposable { const updatedSettingsText = JSON.stringify(settings, null, '\t'); await workspace.fs.writeFile(settingsUri, new TextEncoder().encode(updatedSettingsText)); - Logger.log(`Removed ${removedCount} duplicate manual MCP configuration(s) from ${settingsPath}`); + Logger.log(`Removed ${removedCount} duplicate manual MCP configuration(s) from ${settingsUri.fsPath}`); if (this.container.telemetry.enabled) { this.container.telemetry.sendEvent('mcp/uninstall/duplicate', { - app: appName, + app: settingsUri.fsPath, source: 'gk-mcp-provider', }); } @@ -204,71 +199,6 @@ export class GkMcpProvider implements McpServerDefinitionProvider, Disposable { } } - private getUserSettingsPath(appName: string): string | null { - const platform = getPlatform(); - const home = homedir(); - const appData = processEnv.APPDATA || join(home, 'AppData', 'Roaming'); - - switch (appName) { - case 'vscode': - switch (platform) { - case 'windows': - return join(appData, 'Code', 'User', 'settings.json'); - case 'macOS': - return join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'); - default: // linux - return join(home, '.config', 'Code', 'User', 'settings.json'); - } - case 'vscode-insiders': - switch (platform) { - case 'windows': - return join(appData, 'Code - Insiders', 'User', 'settings.json'); - case 'macOS': - return join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'settings.json'); - default: // linux - return join(home, '.config', 'Code - Insiders', 'User', 'settings.json'); - } - case 'vscode-exploration': - switch (platform) { - case 'windows': - return join(appData, 'Code - Exploration', 'User', 'settings.json'); - case 'macOS': - return join(home, 'Library', 'Application Support', 'Code - Exploration', 'User', 'settings.json'); - default: // linux - return join(home, '.config', 'Code - Exploration', 'User', 'settings.json'); - } - case 'cursor': - switch (platform) { - case 'windows': - return join(appData, 'Cursor', 'User', 'settings.json'); - case 'macOS': - return join(home, 'Library', 'Application Support', 'Cursor', 'User', 'settings.json'); - default: // linux - return join(home, '.config', 'Cursor', 'User', 'settings.json'); - } - case 'windsurf': - switch (platform) { - case 'windows': - return join(appData, 'Windsurf', 'User', 'settings.json'); - case 'macOS': - return join(home, 'Library', 'Application Support', 'Windsurf', 'User', 'settings.json'); - default: // linux - return join(home, '.config', 'Windsurf', 'User', 'settings.json'); - } - case 'codium': - switch (platform) { - case 'windows': - return join(appData, 'VSCodium', 'User', 'settings.json'); - case 'macOS': - return join(home, 'Library', 'Application Support', 'VSCodium', 'User', 'settings.json'); - default: // linux - return join(home, '.config', 'VSCodium', 'User', 'settings.json'); - } - default: - return null; - } - } - private parseJsonWithComments(text: string): Record { // Simple JSON comment remover - removes // and /* */ comments // This is a simplified version; VS Code uses jsonc-parser for full support