Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/telemetry-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/constants.telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -526,6 +528,11 @@ export interface MCPSetupFailedEvent {
'error.message'?: string;
}

export interface MCPUninstallDuplicateEvent {
app: string;
source: Sources;
}

interface CloudIntegrationsConnectingEvent {
'integration.ids': string | undefined;
}
Expand Down
118 changes: 117 additions & 1 deletion src/env/node/gk/mcp/integration.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
});
Expand All @@ -120,6 +123,119 @@ export class GkMcpProvider implements McpServerDefinitionProvider, Disposable {
return undefined;
}

@debug()
private async removeDuplicateManualMcpConfigurations(): Promise<void> {
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<string, unknown>;
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<string, unknown> {
// 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<string, unknown>;
}

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<string, unknown>;
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;

Expand Down