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..c28503bbd9694 100644 --- a/src/env/node/gk/mcp/integration.ts +++ b/src/env/node/gk/mcp/integration.ts @@ -1,5 +1,5 @@ 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'; @@ -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.removeDuplicateManualMcpConfigurations(); + let output = await runCLICommand(['mcp', 'config', appName, '--source=gitlens', `--scheme=${env.uriScheme}`], { cwd: cliPath, }); @@ -120,6 +123,119 @@ export class GkMcpProvider implements McpServerDefinitionProvider, Disposable { return undefined; } + @debug() + private async removeDuplicateManualMcpConfigurations(): Promise { + try { + // 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: ${settingsUri.fsPath}`); + 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 ${settingsUri.fsPath}`); + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('mcp/uninstall/duplicate', { + app: settingsUri.fsPath, + source: 'gk-mcp-provider', + }); + } + } catch (ex) { + // Log error but don't fail the overall process + Logger.error(`Error removing duplicate MCP configurations: ${ex}`); + } + } + + 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;