|
| 1 | +import { homedir } from 'os'; |
| 2 | +import { join } from 'path'; |
| 3 | +import { env as processEnv } from 'process'; |
1 | 4 | import type { Event, McpServerDefinition, McpServerDefinitionProvider } from 'vscode'; |
2 | | -import { Disposable, env, EventEmitter, lm, McpStdioServerDefinition } from 'vscode'; |
| 5 | +import { Disposable, env, EventEmitter, lm, McpStdioServerDefinition, Uri, workspace } from 'vscode'; |
3 | 6 | import type { Container } from '../../../../container'; |
4 | 7 | import type { StorageChangeEvent } from '../../../../system/-webview/storage'; |
5 | 8 | import { getHostAppName } from '../../../../system/-webview/vscode'; |
6 | 9 | import { debug, log } from '../../../../system/decorators/log'; |
7 | 10 | import type { Deferrable } from '../../../../system/function/debounce'; |
8 | 11 | import { debounce } from '../../../../system/function/debounce'; |
9 | 12 | import { Logger } from '../../../../system/logger'; |
| 13 | +import { getPlatform } from '../../platform'; |
10 | 14 | import { runCLICommand, toMcpInstallProvider } from '../cli/utils'; |
11 | 15 |
|
12 | 16 | const CLIProxyMCPConfigOutputs = { |
@@ -96,7 +100,7 @@ export class GkMcpProvider implements McpServerDefinitionProvider, Disposable { |
96 | 100 | } |
97 | 101 |
|
98 | 102 | // Clean up any duplicate manual installations before registering the bundled version |
99 | | - await this.uninstallManualMcpIfExists(appName, cliPath); |
| 103 | + await this.removeDuplicateManualMcpConfigurations(appName); |
100 | 104 |
|
101 | 105 | let output = await runCLICommand(['mcp', 'config', appName, '--source=gitlens', `--scheme=${env.uriScheme}`], { |
102 | 106 | cwd: cliPath, |
@@ -124,35 +128,184 @@ export class GkMcpProvider implements McpServerDefinitionProvider, Disposable { |
124 | 128 | } |
125 | 129 |
|
126 | 130 | @debug() |
127 | | - private async uninstallManualMcpIfExists(appName: string, cliPath: string): Promise<void> { |
| 131 | + private async removeDuplicateManualMcpConfigurations(appName: string): Promise<void> { |
128 | 132 | try { |
129 | | - // Check if a manual MCP installation exists by attempting to uninstall it |
130 | | - // The uninstall command will only succeed if there's an existing manual installation |
131 | | - const output = await runCLICommand( |
132 | | - ['mcp', 'uninstall', appName, '--source=gitlens', `--scheme=${env.uriScheme}`], |
133 | | - { |
134 | | - cwd: cliPath, |
135 | | - }, |
136 | | - ); |
137 | | - |
138 | | - // If uninstall succeeded, log and send telemetry |
139 | | - if (output.trim().length > 0) { |
140 | | - Logger.log(`Uninstalled duplicate manual MCP installation for ${appName}`); |
141 | | - |
142 | | - if (this.container.telemetry.enabled) { |
143 | | - this.container.telemetry.sendEvent('mcp/uninstall/duplicate', { |
144 | | - app: appName, |
145 | | - source: 'gk-mcp-provider', |
146 | | - }); |
| 133 | + const settingsPath = this.getUserSettingsPath(appName); |
| 134 | + if (settingsPath == null) { |
| 135 | + Logger.debug(`Unable to determine settings path for ${appName}`); |
| 136 | + return; |
| 137 | + } |
| 138 | + |
| 139 | + const settingsUri = Uri.file(settingsPath); |
| 140 | + |
| 141 | + // Check if settings file exists |
| 142 | + try { |
| 143 | + await workspace.fs.stat(settingsUri); |
| 144 | + } catch { |
| 145 | + // Settings file doesn't exist, nothing to clean up |
| 146 | + Logger.debug(`Settings file does not exist: ${settingsPath}`); |
| 147 | + return; |
| 148 | + } |
| 149 | + |
| 150 | + // Read and parse settings file |
| 151 | + const settingsBytes = await workspace.fs.readFile(settingsUri); |
| 152 | + const settingsText = new TextDecoder().decode(settingsBytes); |
| 153 | + |
| 154 | + // Parse JSON with comments support (VS Code settings.json allows comments) |
| 155 | + const settings = this.parseJsonWithComments(settingsText); |
| 156 | + |
| 157 | + // Check for MCP server configurations |
| 158 | + const mcpServersKey = 'languageModels.chat.mcpServers'; |
| 159 | + if (!settings[mcpServersKey] || typeof settings[mcpServersKey] !== 'object') { |
| 160 | + Logger.debug('No MCP server configurations found'); |
| 161 | + return; |
| 162 | + } |
| 163 | + |
| 164 | + const mcpServers = settings[mcpServersKey] as Record<string, unknown>; |
| 165 | + let removedCount = 0; |
| 166 | + const serversToRemove: string[] = []; |
| 167 | + |
| 168 | + // Look for GitKraken MCP servers that were manually installed |
| 169 | + // These typically have names like "gitkraken" or "GitKraken" and contain |
| 170 | + // the GK CLI executable path |
| 171 | + for (const [serverName, serverConfig] of Object.entries(mcpServers)) { |
| 172 | + if (this.isGitKrakenMcpServer(serverName, serverConfig)) { |
| 173 | + serversToRemove.push(serverName); |
| 174 | + Logger.log(`Found duplicate manual MCP configuration: ${serverName}`); |
147 | 175 | } |
148 | 176 | } |
| 177 | + |
| 178 | + // Remove the servers |
| 179 | + for (const serverName of serversToRemove) { |
| 180 | + mcpServers[serverName] = undefined; |
| 181 | + removedCount++; |
| 182 | + } |
| 183 | + |
| 184 | + if (removedCount === 0) { |
| 185 | + Logger.debug('No duplicate manual MCP configurations found'); |
| 186 | + return; |
| 187 | + } |
| 188 | + |
| 189 | + // Save updated settings |
| 190 | + const updatedSettingsText = JSON.stringify(settings, null, '\t'); |
| 191 | + await workspace.fs.writeFile(settingsUri, new TextEncoder().encode(updatedSettingsText)); |
| 192 | + |
| 193 | + Logger.log(`Removed ${removedCount} duplicate manual MCP configuration(s) from ${settingsPath}`); |
| 194 | + |
| 195 | + if (this.container.telemetry.enabled) { |
| 196 | + this.container.telemetry.sendEvent('mcp/uninstall/duplicate', { |
| 197 | + app: appName, |
| 198 | + source: 'gk-mcp-provider', |
| 199 | + }); |
| 200 | + } |
149 | 201 | } catch (ex) { |
150 | | - // If uninstall fails, it likely means no manual installation exists |
151 | | - // Log the error at debug level but don't fail the overall process |
152 | | - Logger.debug(`No manual MCP installation to uninstall for ${appName}: ${ex}`); |
| 202 | + // Log error but don't fail the overall process |
| 203 | + Logger.error(`Error removing duplicate MCP configurations: ${ex}`); |
| 204 | + } |
| 205 | + } |
| 206 | + |
| 207 | + private getUserSettingsPath(appName: string): string | null { |
| 208 | + const platform = getPlatform(); |
| 209 | + const home = homedir(); |
| 210 | + const appData = processEnv.APPDATA || join(home, 'AppData', 'Roaming'); |
| 211 | + |
| 212 | + switch (appName) { |
| 213 | + case 'vscode': |
| 214 | + switch (platform) { |
| 215 | + case 'windows': |
| 216 | + return join(appData, 'Code', 'User', 'settings.json'); |
| 217 | + case 'macOS': |
| 218 | + return join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'); |
| 219 | + default: // linux |
| 220 | + return join(home, '.config', 'Code', 'User', 'settings.json'); |
| 221 | + } |
| 222 | + case 'vscode-insiders': |
| 223 | + switch (platform) { |
| 224 | + case 'windows': |
| 225 | + return join(appData, 'Code - Insiders', 'User', 'settings.json'); |
| 226 | + case 'macOS': |
| 227 | + return join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'settings.json'); |
| 228 | + default: // linux |
| 229 | + return join(home, '.config', 'Code - Insiders', 'User', 'settings.json'); |
| 230 | + } |
| 231 | + case 'vscode-exploration': |
| 232 | + switch (platform) { |
| 233 | + case 'windows': |
| 234 | + return join(appData, 'Code - Exploration', 'User', 'settings.json'); |
| 235 | + case 'macOS': |
| 236 | + return join(home, 'Library', 'Application Support', 'Code - Exploration', 'User', 'settings.json'); |
| 237 | + default: // linux |
| 238 | + return join(home, '.config', 'Code - Exploration', 'User', 'settings.json'); |
| 239 | + } |
| 240 | + case 'cursor': |
| 241 | + switch (platform) { |
| 242 | + case 'windows': |
| 243 | + return join(appData, 'Cursor', 'User', 'settings.json'); |
| 244 | + case 'macOS': |
| 245 | + return join(home, 'Library', 'Application Support', 'Cursor', 'User', 'settings.json'); |
| 246 | + default: // linux |
| 247 | + return join(home, '.config', 'Cursor', 'User', 'settings.json'); |
| 248 | + } |
| 249 | + case 'windsurf': |
| 250 | + switch (platform) { |
| 251 | + case 'windows': |
| 252 | + return join(appData, 'Windsurf', 'User', 'settings.json'); |
| 253 | + case 'macOS': |
| 254 | + return join(home, 'Library', 'Application Support', 'Windsurf', 'User', 'settings.json'); |
| 255 | + default: // linux |
| 256 | + return join(home, '.config', 'Windsurf', 'User', 'settings.json'); |
| 257 | + } |
| 258 | + case 'codium': |
| 259 | + switch (platform) { |
| 260 | + case 'windows': |
| 261 | + return join(appData, 'VSCodium', 'User', 'settings.json'); |
| 262 | + case 'macOS': |
| 263 | + return join(home, 'Library', 'Application Support', 'VSCodium', 'User', 'settings.json'); |
| 264 | + default: // linux |
| 265 | + return join(home, '.config', 'VSCodium', 'User', 'settings.json'); |
| 266 | + } |
| 267 | + default: |
| 268 | + return null; |
153 | 269 | } |
154 | 270 | } |
155 | 271 |
|
| 272 | + private parseJsonWithComments(text: string): Record<string, unknown> { |
| 273 | + // Simple JSON comment remover - removes // and /* */ comments |
| 274 | + // This is a simplified version; VS Code uses jsonc-parser for full support |
| 275 | + const withoutComments = text |
| 276 | + .replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments |
| 277 | + .replace(/\/\/.*/g, ''); // Remove // comments |
| 278 | + |
| 279 | + return JSON.parse(withoutComments) as Record<string, unknown>; |
| 280 | + } |
| 281 | + |
| 282 | + private isGitKrakenMcpServer(serverName: string, serverConfig: unknown): boolean { |
| 283 | + // Check if this is a GitKraken MCP server by looking for: |
| 284 | + // 1. Server name matches GitKraken variants |
| 285 | + // 2. Command contains 'gk' executable |
| 286 | + // 3. Args contain '--source=gitlens' or scheme parameter |
| 287 | + |
| 288 | + const nameMatches = /^git[_-]?kraken$/i.test(serverName); |
| 289 | + |
| 290 | + if (typeof serverConfig !== 'object' || serverConfig == null) { |
| 291 | + return false; |
| 292 | + } |
| 293 | + |
| 294 | + const config = serverConfig as Record<string, unknown>; |
| 295 | + const command = typeof config.command === 'string' ? config.command : ''; |
| 296 | + const args = Array.isArray(config.args) ? config.args : []; |
| 297 | + |
| 298 | + // Check if command contains gk executable |
| 299 | + const commandMatches = command.includes('/gk') || command.includes('\\gk') || command.endsWith('gk.exe'); |
| 300 | + |
| 301 | + // Check if args contain source=gitlens |
| 302 | + const argsMatch = args.some((arg: unknown) => |
| 303 | + typeof arg === 'string' && arg.includes('--source=gitlens') |
| 304 | + ); |
| 305 | + |
| 306 | + return nameMatches && commandMatches && argsMatch; |
| 307 | + } |
| 308 | + |
156 | 309 | private onRegistrationCompleted(_cliVersion?: string | undefined) { |
157 | 310 | if (!this.container.telemetry.enabled) return; |
158 | 311 |
|
|
0 commit comments