Skip to content

Commit 8e760a6

Browse files
Adds support for mcp server installation
1 parent fe19cfa commit 8e760a6

File tree

6 files changed

+318
-3
lines changed

6 files changed

+318
-3
lines changed

contributions.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,10 @@
268268
"icon": "$(sparkle)",
269269
"commandPalette": "gitlens:enabled && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
270270
},
271+
"gitlens.ai.mcp.install": {
272+
"label": "Install MCP Server",
273+
"commandPalette": "gitlens:enabled && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
274+
},
271275
"gitlens.ai.rebaseOntoCommit:graph": {
272276
"label": "AI Rebase Current Branch onto Commit (Preview)...",
273277
"icon": "$(sparkle)",

docs/telemetry-events.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6248,6 +6248,11 @@
62486248
"category": "GitLens",
62496249
"icon": "$(sparkle)"
62506250
},
6251+
{
6252+
"command": "gitlens.ai.mcp.install",
6253+
"title": "Install MCP Server",
6254+
"category": "GitLens"
6255+
},
62516256
{
62526257
"command": "gitlens.ai.rebaseOntoCommit:graph",
62536258
"title": "AI Rebase Current Branch onto Commit (Preview)...",
@@ -10766,6 +10771,10 @@
1076610771
"command": "gitlens.ai.generateRebase",
1076710772
"when": "gitlens:enabled && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
1076810773
},
10774+
{
10775+
"command": "gitlens.ai.mcp.install",
10776+
"when": "gitlens:enabled && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
10777+
},
1076910778
{
1077010779
"command": "gitlens.ai.rebaseOntoCommit:graph",
1077110780
"when": "false"

src/constants.commands.generated.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,7 @@ export type ContributedPaletteCommands =
647647
| 'gitlens.ai.generateCommitMessage'
648648
| 'gitlens.ai.generateCommits'
649649
| 'gitlens.ai.generateRebase'
650+
| 'gitlens.ai.mcp.install'
650651
| 'gitlens.ai.switchProvider'
651652
| 'gitlens.applyPatchFromClipboard'
652653
| 'gitlens.associateIssueWithBranch'

src/constants.storage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export type DeprecatedGlobalStorage = {
6363
};
6464

6565
export type GlobalStorage = {
66+
'ai:mcp:attemptInstall': boolean;
6667
avatars: [string, StoredAvatar][];
6768
'confirm:ai:generateCommits': boolean;
6869
'confirm:ai:generateRebase': boolean;

src/env/node/gk/cli/integration.ts

Lines changed: 302 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import { arch } from 'process';
12
import type { ConfigurationChangeEvent } from 'vscode';
2-
import { Disposable } from 'vscode';
3+
import { Disposable, ProgressLocation, Uri, window, workspace } from 'vscode';
34
import type { Container } from '../../../../container';
5+
import { registerCommand } from '../../../../system/-webview/command';
46
import { configuration } from '../../../../system/-webview/configuration';
7+
import { getContext } from '../../../../system/-webview/context';
8+
import { Logger } from '../../../../system/logger';
9+
import { run } from '../../git/shell';
10+
import { getPlatform, isWeb } from '../../platform';
511
import { CliCommandHandlers } from './commands';
612
import type { IpcServer } from './ipcServer';
713
import { createIpcServer } from './ipcServer';
@@ -18,9 +24,15 @@ export class GkCliIntegrationProvider implements Disposable {
1824
private _runningDisposable: Disposable | undefined;
1925

2026
constructor(private readonly container: Container) {
21-
this._disposable = configuration.onDidChange(e => this.onConfigurationChanged(e));
27+
this._disposable = Disposable.from(
28+
configuration.onDidChange(e => this.onConfigurationChanged(e)),
29+
...this.registerCommands(),
30+
);
2231

2332
this.onConfigurationChanged();
33+
setTimeout(() => {
34+
void this.installMCPIfNeeded(true);
35+
}, 10000 + Math.floor(Math.random() * 20000));
2436
}
2537

2638
dispose(): void {
@@ -56,4 +68,292 @@ export class GkCliIntegrationProvider implements Disposable {
5668
this._runningDisposable?.dispose();
5769
this._runningDisposable = undefined;
5870
}
71+
72+
private async installMCPIfNeeded(silent?: boolean): Promise<void> {
73+
try {
74+
if (silent && this.container.storage.get('ai:mcp:attemptInstall', false)) {
75+
return;
76+
}
77+
78+
// Store the flag to indicate that we have made the attempt
79+
await this.container.storage.store('ai:mcp:attemptInstall', true);
80+
81+
if (configuration.get('ai.enabled') === false) {
82+
const message = 'Cannot install MCP: AI is disabled in settings';
83+
Logger.log(message);
84+
if (silent !== true) {
85+
void window.showErrorMessage(message);
86+
}
87+
return;
88+
}
89+
90+
if ( getContext('gitlens:gk:organization:ai:enabled', true) !== true) {
91+
const message = 'Cannot install MCP: AI is disabled by your organization';
92+
Logger.log(message);
93+
if (silent !== true) {
94+
void window.showErrorMessage(message);
95+
}
96+
return;
97+
}
98+
99+
if (isWeb) {
100+
const message = 'Cannot install MCP: web environment is not supported';
101+
Logger.log(message);
102+
if (silent !== true) {
103+
void window.showErrorMessage(message);
104+
}
105+
return;
106+
}
107+
108+
// Detect platform and architecture
109+
const platform = getPlatform();
110+
111+
// Map platform names for the API and get architecture
112+
let platformName: string;
113+
let architecture: string;
114+
115+
switch (arch) {
116+
case 'x64':
117+
architecture = 'x64';
118+
break;
119+
case 'arm64':
120+
architecture = 'arm64';
121+
break;
122+
default:
123+
architecture = 'x86'; // Default to x86 for other architectures
124+
break;
125+
}
126+
127+
switch (platform) {
128+
case 'windows':
129+
platformName = 'windows';
130+
break;
131+
case 'macOS':
132+
platformName = 'darwin';
133+
break;
134+
case 'linux':
135+
platformName = 'linux';
136+
break;
137+
default: {
138+
const message = `Skipping MCP installation: unsupported platform ${platform}`;
139+
Logger.log(message);
140+
if (silent !== true) {
141+
void window.showErrorMessage(`Cannot install MCP integration: unsupported platform ${platform}`);
142+
}
143+
return;
144+
}
145+
}
146+
147+
// Wrap the main installation process with progress indicator if not silent
148+
const installationTask = async () => {
149+
let mcpInstallerPath: Uri | undefined;
150+
let mcpExtractedFolderPath: Uri | undefined;
151+
let mcpExtractedPath: Uri | undefined;
152+
153+
try {
154+
// Download the MCP proxy installer
155+
const proxyUrl = `https://api.gitkraken.dev/releases/gkcli-proxy/production/${platformName}/${architecture}/active`;
156+
157+
let response = await fetch(proxyUrl);
158+
if (!response.ok) {
159+
const errorMsg = `Failed to get MCP installer proxy: ${response.status} ${response.statusText}`;
160+
Logger.error(errorMsg);
161+
throw new Error(errorMsg);
162+
}
163+
164+
let downloadUrl: string | undefined;
165+
try {
166+
const mcpInstallerInfo: { version?: string; packages?: { zip?: string } } | undefined = await response.json() as any;
167+
downloadUrl = mcpInstallerInfo?.packages?.zip;
168+
} catch (ex) {
169+
const errorMsg = `Failed to parse MCP installer info: ${ex}`;
170+
Logger.error(errorMsg);
171+
throw new Error(errorMsg);
172+
}
173+
174+
if (downloadUrl == null) {
175+
const errorMsg = 'Failed to find download URL for MCP proxy installer';
176+
Logger.error(errorMsg);
177+
throw new Error(errorMsg);
178+
}
179+
180+
response = await fetch(downloadUrl);
181+
if (!response.ok) {
182+
const errorMsg = `Failed to download MCP proxy installer: ${response.status} ${response.statusText}`;
183+
Logger.error(errorMsg);
184+
throw new Error(errorMsg);
185+
}
186+
187+
const installerData = await response.arrayBuffer();
188+
if (installerData.byteLength === 0) {
189+
const errorMsg = 'Downloaded installer is empty';
190+
Logger.error(errorMsg);
191+
throw new Error(errorMsg);
192+
}
193+
// installer file name is the last part of the download URL
194+
const installerFileName = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1);
195+
mcpInstallerPath = Uri.joinPath(this.container.context.globalStorageUri, installerFileName);
196+
197+
// Ensure the global storage directory exists
198+
await workspace.fs.createDirectory(this.container.context.globalStorageUri);
199+
200+
// Write the installer to the extension storage
201+
await workspace.fs.writeFile(mcpInstallerPath, new Uint8Array(installerData));
202+
Logger.log(`Downloaded MCP proxy installer successfully`);
203+
204+
try {
205+
// Use the run function to extract the installer file from the installer zip
206+
if (platform === 'windows') {
207+
// On Windows, use PowerShell to extract the zip file
208+
await run(
209+
'powershell.exe',
210+
['-Command', `Expand-Archive -Path "${mcpInstallerPath.fsPath}" -DestinationPath "${this.container.context.globalStorageUri.fsPath}"`],
211+
'utf8',
212+
);
213+
} else {
214+
// On Unix-like systems, use the unzip command to extract the zip file
215+
await run(
216+
'unzip',
217+
['-o', mcpInstallerPath.fsPath, '-d', this.container.context.globalStorageUri.fsPath],
218+
'utf8',
219+
);
220+
}
221+
// The gk.exe file should be in a subfolder named after the installer file name
222+
const extractedFolderName = installerFileName.replace(/\.zip$/, '');
223+
mcpExtractedFolderPath = Uri.joinPath(this.container.context.globalStorageUri, extractedFolderName);
224+
mcpExtractedPath = Uri.joinPath(mcpExtractedFolderPath, 'gk.exe');
225+
226+
// Check using stat to make sure the newly extracted file exists.
227+
await workspace.fs.stat(mcpExtractedPath);
228+
} catch (error) {
229+
const errorMsg = `Failed to extract MCP installer: ${error}`;
230+
Logger.error(errorMsg);
231+
throw new Error(errorMsg);
232+
}
233+
234+
// Get the VS Code settings.json file path
235+
// TODO: Make this path point to the current vscode profile's settings.json once the API supports it
236+
const settingsPath = `${this.container.context.globalStorageUri.fsPath}\\..\\..\\settings.json`;
237+
238+
// Configure the MCP server in settings.json
239+
try {
240+
await run(mcpExtractedPath.fsPath, ['mcp', 'install', 'vscode', '--file-path', settingsPath], 'utf8');
241+
} catch {
242+
// Try alternative execution methods based on platform
243+
try {
244+
Logger.log('Attempting alternative execution method for MCP install...');
245+
if (platform === 'windows') {
246+
// On Windows, try running with cmd.exe
247+
await run(
248+
'cmd.exe',
249+
[
250+
'/c',
251+
`"${mcpExtractedPath.fsPath}"`,
252+
'mcp',
253+
'install',
254+
'vscode',
255+
'--file-path',
256+
`"${settingsPath}"`,
257+
],
258+
'utf8',
259+
);
260+
} else {
261+
// On Unix-like systems, try running with sh
262+
await run(
263+
'/bin/sh',
264+
['-c', `"${mcpExtractedPath.fsPath}" mcp install vscode --file-path "${settingsPath}"`],
265+
'utf8',
266+
);
267+
}
268+
} catch (altError) {
269+
const errorMsg = `MCP server configuration failed: ${altError}`;
270+
Logger.error(errorMsg);
271+
throw new Error(errorMsg);
272+
}
273+
}
274+
275+
// Verify that the MCP server was actually configured in settings.json
276+
try {
277+
const settingsUri = Uri.file(settingsPath);
278+
const settingsData = await workspace.fs.readFile(settingsUri);
279+
const settingsJson = JSON.parse(settingsData.toString());
280+
281+
if (!settingsJson?.['mcp']?.['servers']?.['GitKraken']) {
282+
const errorMsg = 'MCP server configuration verification failed: Unable to update MCP settings';
283+
Logger.error(errorMsg);
284+
throw new Error(errorMsg);
285+
}
286+
287+
Logger.log('MCP configured successfully - GitKraken server verified in settings.json');
288+
} catch (verifyError) {
289+
if (verifyError instanceof Error && verifyError.message.includes('verification failed')) {
290+
// Re-throw verification errors as-is
291+
throw verifyError;
292+
}
293+
// Handle file read/parse errors
294+
const errorMsg = `Failed to verify MCP configuration in settings.json: ${verifyError}`;
295+
Logger.error(errorMsg);
296+
throw new Error(errorMsg);
297+
}
298+
} finally {
299+
// Always clean up downloaded/extracted files, even if something failed
300+
if (mcpInstallerPath != null) {
301+
try {
302+
await workspace.fs.delete(mcpInstallerPath);
303+
} catch (error) {
304+
Logger.warn(`Failed to delete MCP installer zip file: ${error}`);
305+
}
306+
}
307+
308+
if (mcpExtractedPath != null) {
309+
try {
310+
await workspace.fs.delete(mcpExtractedPath);
311+
} catch (error) {
312+
Logger.warn(`Failed to delete MCP extracted executable: ${error}`);
313+
}
314+
}
315+
316+
if (mcpExtractedFolderPath != null) {
317+
try {
318+
await workspace.fs.delete(Uri.joinPath(mcpExtractedFolderPath, 'README.md'));
319+
await workspace.fs.delete(mcpExtractedFolderPath);
320+
} catch (error) {
321+
Logger.warn(`Failed to delete MCP extracted folder: ${error}`);
322+
}
323+
}
324+
}
325+
};
326+
327+
// Execute the installation task with or without progress indicator
328+
if (silent !== true) {
329+
await window.withProgress(
330+
{
331+
location: ProgressLocation.Notification,
332+
title: 'Installing MCP integration...',
333+
cancellable: false,
334+
},
335+
async () => {
336+
await installationTask();
337+
}
338+
);
339+
340+
// Show success notification if not silent
341+
void window.showInformationMessage('MCP integration installed successfully');
342+
} else {
343+
await installationTask();
344+
}
345+
346+
} catch (error) {
347+
Logger.error(`Error during MCP installation: ${error}`);
348+
349+
// Show error notification if not silent
350+
if (silent !== true) {
351+
void window.showErrorMessage(`Failed to install MCP integration: ${error instanceof Error ? error.message : String(error)}`);
352+
}
353+
}
354+
}
355+
356+
private registerCommands(): Disposable[] {
357+
return [registerCommand('gitlens.ai.mcp.install', () => this.installMCPIfNeeded())];
358+
}
59359
}

0 commit comments

Comments
 (0)