Skip to content

Commit 49d50c6

Browse files
Merge pull request #11066 from srnagar/az-mcp
Azure MCP support for GitHub Copilot
2 parents d9e9e1b + b3cfb3c commit 49d50c6

File tree

13 files changed

+630
-0
lines changed

13 files changed

+630
-0
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
dependencies {
2+
implementation(project(":azure-intellij-plugin-lib"))
3+
implementation(project(":azure-intellij-plugin-lib-java"))
4+
implementation("com.microsoft.azure:azure-toolkit-common-lib")
5+
implementation("com.microsoft.azure:azure-toolkit-ide-common-lib")
6+
testImplementation("org.junit.jupiter:junit-jupiter:5.8.1")
7+
testImplementation("org.mockito:mockito-core:3.9.0")
8+
9+
intellijPlatform {
10+
// Plugin Dependencies. Uses `platformBundledPlugins` property from the gradle.properties file for bundled IntelliJ Platform plugins.
11+
bundledPlugin("com.intellij.java")
12+
bundledPlugin("org.jetbrains.idea.maven")
13+
bundledPlugin("org.jetbrains.idea.maven.model")
14+
bundledPlugin("com.intellij.gradle")
15+
}
16+
17+
tasks.named("test", Test::class) {
18+
useJUnitPlatform()
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package com.microsoft.azure.toolkit.intellij.azuremcp;
2+
3+
import com.intellij.openapi.application.PathManager;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.apache.commons.codec.digest.DigestUtils;
6+
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
7+
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
8+
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
9+
import org.apache.commons.lang3.StringUtils;
10+
import org.apache.commons.lang3.SystemUtils;
11+
import org.jetbrains.annotations.NotNull;
12+
13+
import javax.annotation.Nullable;
14+
import java.io.File;
15+
import java.io.FileInputStream;
16+
import java.io.FileOutputStream;
17+
import java.io.IOException;
18+
import java.nio.file.Files;
19+
import java.nio.file.Path;
20+
import java.nio.file.StandardOpenOption;
21+
import java.util.Optional;
22+
23+
@Slf4j
24+
public class AzureMcpPackageManager {
25+
private final GithubClient gitHubClient;
26+
private final String platform;
27+
28+
public AzureMcpPackageManager() {
29+
this.gitHubClient = new GithubClient();
30+
this.platform = getPlatformIdentifier();
31+
}
32+
33+
@Nullable
34+
public synchronized File getAzureMcpExecutable() {
35+
try {
36+
final GithubRelease latestRelease = gitHubClient.getLatestAzureMcpRelease();
37+
if (latestRelease != null && latestRelease.getAssets() != null) {
38+
final String tagName = latestRelease.getTagName();
39+
log.info("Latest version of Azure MCP: " + tagName);
40+
41+
final Optional<GithubAsset> githubAsset = latestRelease.getAssets()
42+
.stream()
43+
.filter(asset -> asset.getName().contains(platform))
44+
.findFirst();
45+
46+
if (githubAsset.isPresent()) {
47+
final GithubAsset asset = githubAsset.get();
48+
log.info("Azure MCP package for current platform: " + asset.getName());
49+
final long startTime = System.currentTimeMillis();
50+
final String azMcpDir = PathManager.getPluginsPath() + "/azure-toolkit-for-intellij/azmcp";
51+
final File azMcpDirFile = new File(azMcpDir);
52+
if (azMcpDirFile.exists() || azMcpDirFile.mkdirs()) {
53+
final Path versionFile = Path.of(azMcpDir + "/version.txt");
54+
55+
final File extractedDir = new File(azMcpDirFile, "/azmcp_package_" + tagName);
56+
extractedDir.mkdirs();
57+
final String executablePath = extractedDir.getAbsolutePath() + getExecutableRelativePath();
58+
final File azMcpExe = new File(executablePath);
59+
if (!azMcpExe.exists()) {
60+
Files.writeString(versionFile, tagName, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
61+
final File azMcpTgz = new File(azMcpDir, "azmcp_" + tagName + ".tgz");
62+
log.info("Downloading Azure MCP Server to: " + azMcpTgz.getAbsolutePath());
63+
final boolean downloaded = gitHubClient.downloadToFile(asset.getBrowserDownloadUrl(), azMcpTgz);
64+
if (downloaded && digestMatches(azMcpTgz, asset.getDigest())) {
65+
log.info("Downloaded Azure MCP Server successfully in " + (System.currentTimeMillis() - startTime) + " ms");
66+
log.info("Extracting Azure MCP Server to: " + extractedDir.getAbsolutePath());
67+
extractTarGz(azMcpTgz, extractedDir);
68+
log.info("Azure MCP Server extracted successfully to: " + extractedDir.getAbsolutePath());
69+
}
70+
}
71+
72+
if (azMcpExe.exists() && (azMcpExe.canExecute() || azMcpExe.setExecutable(true))) {
73+
log.info("Azure MCP Server executable found at: " + azMcpExe.getAbsolutePath());
74+
return azMcpExe;
75+
}
76+
}
77+
}
78+
}
79+
} catch (final IOException e) {
80+
System.err.println("Error reading Azure MCP Server version: " + e.getMessage());
81+
}
82+
return null;
83+
}
84+
85+
private boolean digestMatches(File azMcpTgz, String expectedDigest) {
86+
try {
87+
// GitHub releases API computes the SHA-256 digest of the file contents.
88+
// https://github.blog/changelog/2025-06-03-releases-now-expose-digests-for-release-assets/
89+
final String downloadFileDigest = DigestUtils.sha256Hex(new FileInputStream(azMcpTgz));
90+
return StringUtils.equalsIgnoreCase("sha256:" + downloadFileDigest, expectedDigest);
91+
} catch (final Exception e) {
92+
log.error("Failed to calculate file digest", e);
93+
return false;
94+
}
95+
}
96+
97+
public synchronized void cleanup() {
98+
try {
99+
final String azMcpDir = PathManager.getPluginsPath() + "/azure-toolkit-for-intellij/azmcp";
100+
final File azMcpDirFile = new File(azMcpDir);
101+
if (!azMcpDirFile.exists()) {
102+
return;
103+
}
104+
105+
final Path versionFile = Path.of(azMcpDir + "/version.txt");
106+
String currentVersion = null;
107+
if (versionFile.toFile().exists()) {
108+
currentVersion = new String(Files.readAllBytes(versionFile));
109+
}
110+
111+
final Path currentPackage = Path.of(azMcpDir + "/azmcp_package_" + currentVersion).toAbsolutePath();
112+
Files.list(Path.of(azMcpDir))
113+
.filter(path -> !path.equals(currentPackage))
114+
.filter(path -> !path.equals(versionFile))
115+
.forEach(path -> {
116+
delete(path);
117+
});
118+
119+
} catch (final Exception exception) {
120+
System.err.println("Error cleaning up Azure MCP Server: " + exception.getMessage());
121+
}
122+
}
123+
124+
private static void delete(Path path) {
125+
try {
126+
if (path.toFile().isDirectory()) {
127+
Files.list(path).forEach(AzureMcpPackageManager::delete);
128+
}
129+
Files.delete(path);
130+
} catch (final IOException e) {
131+
System.err.println("Error deleting file: " + path.toString());
132+
}
133+
}
134+
135+
private @NotNull String getExecutableRelativePath() {
136+
String executablePath = "/package/dist/azmcp";
137+
if (SystemUtils.IS_OS_WINDOWS) {
138+
executablePath += ".exe";
139+
}
140+
return executablePath;
141+
}
142+
143+
private void extractTarGz(File tarGzFile, File destDir) throws IOException {
144+
try (final TarArchiveInputStream tarIn = new TarArchiveInputStream(
145+
new GzipCompressorInputStream(
146+
new FileInputStream(tarGzFile)))) {
147+
TarArchiveEntry entry;
148+
while ((entry = tarIn.getNextTarEntry()) != null) {
149+
final File outputFile = new File(destDir, entry.getName());
150+
if (entry.isDirectory()) {
151+
if (!outputFile.exists()) {
152+
outputFile.mkdirs();
153+
}
154+
} else {
155+
outputFile.getParentFile().mkdirs();
156+
try (final FileOutputStream fos = new FileOutputStream(outputFile)) {
157+
tarIn.transferTo(fos);
158+
}
159+
}
160+
}
161+
}
162+
}
163+
164+
private static String getPlatformIdentifier() {
165+
// Operating System detection
166+
String os = null;
167+
if (SystemUtils.IS_OS_WINDOWS) {
168+
os = "win32";
169+
} else if (SystemUtils.IS_OS_LINUX) {
170+
os = "linux";
171+
} else if (SystemUtils.IS_OS_MAC) {
172+
os = "darwin";
173+
} else {
174+
throw new RuntimeException("Unsupported OS " + SystemUtils.OS_NAME);
175+
}
176+
final String arch = getArch();
177+
return os + "-" + arch;
178+
}
179+
180+
private static String getArch() {
181+
final String arch = SystemUtils.OS_ARCH.toLowerCase();
182+
if (arch.contains("amd64") || arch.contains("x86_64") || arch.contains("x64")) {
183+
return "x64";
184+
}
185+
if (arch.contains("aarch64") || arch.contains("arm64")) {
186+
return "arm64";
187+
}
188+
throw new RuntimeException("Unsupported architecture: " + arch);
189+
}
190+
191+
192+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.microsoft.azure.toolkit.intellij.azuremcp;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import lombok.Data;
5+
6+
@Data
7+
public class GithubAsset {
8+
@JsonProperty("url")
9+
private String url;
10+
@JsonProperty("browser_download_url")
11+
private String browserDownloadUrl;
12+
@JsonProperty("id")
13+
private long id;
14+
@JsonProperty("node_id")
15+
private String nodeId;
16+
@JsonProperty("name")
17+
private String name;
18+
@JsonProperty("label")
19+
private String label;
20+
@JsonProperty("state")
21+
private String state;
22+
@JsonProperty("content_type")
23+
private String contentType;
24+
@JsonProperty("size")
25+
private long size;
26+
@JsonProperty("digest")
27+
private String digest;
28+
@JsonProperty("download_count")
29+
private int downloadCount;
30+
@JsonProperty("created_at")
31+
private String createdAt;
32+
@JsonProperty("updated_at")
33+
private String updatedAt;
34+
@JsonProperty("uploader")
35+
private GithubUser uploader;
36+
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.microsoft.azure.toolkit.intellij.azuremcp;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
import com.fasterxml.jackson.core.JsonParser;
5+
import com.fasterxml.jackson.core.type.TypeReference;
6+
import com.fasterxml.jackson.databind.DeserializationFeature;
7+
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import com.fasterxml.jackson.databind.SerializationFeature;
9+
import org.apache.http.client.methods.CloseableHttpResponse;
10+
import org.apache.http.client.methods.HttpUriRequest;
11+
import org.apache.http.client.methods.RequestBuilder;
12+
import org.apache.http.impl.client.CloseableHttpClient;
13+
import org.apache.http.impl.client.HttpClients;
14+
15+
import java.io.Closeable;
16+
import java.io.File;
17+
import java.io.FileOutputStream;
18+
import java.io.IOException;
19+
import java.util.List;
20+
21+
public class GithubClient implements Closeable {
22+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
23+
.configure(JsonParser.Feature.ALLOW_COMMENTS, true)
24+
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
25+
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
26+
.enable(SerializationFeature.INDENT_OUTPUT);
27+
private static final String AZURE_MCP_RELEASE_URL = "https://aka.ms/azmcp/releases";
28+
private static final TypeReference<List<GithubRelease>> GITHUB_RELEASE_LIST_TYPE = new TypeReference<>() {
29+
};
30+
31+
private final CloseableHttpClient httpClient = HttpClients.createDefault();
32+
33+
public GithubRelease getLatestAzureMcpRelease() {
34+
final HttpUriRequest request = RequestBuilder.get().setUri(AZURE_MCP_RELEASE_URL).build();
35+
try (final CloseableHttpResponse response = httpClient.execute(request)) {
36+
final List<GithubRelease> releases = OBJECT_MAPPER.readValue(response.getEntity().getContent(), GITHUB_RELEASE_LIST_TYPE);
37+
return releases.stream().findFirst().orElse(null);
38+
} catch (final IOException exception) {
39+
return null;
40+
}
41+
}
42+
43+
public boolean downloadToFile(String downloadUrl, File downloadFile) {
44+
final HttpUriRequest downloadRequest = RequestBuilder.get().setUri(downloadUrl).build();
45+
try (final CloseableHttpResponse downloadResponse = httpClient.execute(downloadRequest);
46+
final FileOutputStream fos = new FileOutputStream(downloadFile)) {
47+
downloadResponse.getEntity().getContent().transferTo(fos);
48+
return true;
49+
} catch (final IOException exception) {
50+
return false;
51+
}
52+
}
53+
54+
@Override
55+
public void close() throws IOException {
56+
this.httpClient.close();
57+
}
58+
}

0 commit comments

Comments
 (0)