diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs
index 91b2e09d..0c57bb4d 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs
@@ -1,9 +1,11 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-using Microsoft.Extensions.Logging;
+using Microsoft.Agents.A365.DevTools.Cli.Helpers;
using Microsoft.Agents.A365.DevTools.Cli.Models;
using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Extensions.Logging;
using System.CommandLine;
+using static Microsoft.Agents.A365.DevTools.Cli.Helpers.PackageMCPServerHelper;
namespace Microsoft.Agents.A365.DevTools.Cli.Commands;
@@ -35,6 +37,7 @@ public static Command CreateCommand(
developMcpCommand.AddCommand(CreateUnpublishSubcommand(logger, toolingService));
developMcpCommand.AddCommand(CreateApproveSubcommand(logger, toolingService));
developMcpCommand.AddCommand(CreateBlockSubcommand(logger, toolingService));
+ developMcpCommand.AddCommand(CreatePackageMCPServerSubCommand(logger, toolingService));
return developMcpCommand;
}
@@ -763,6 +766,57 @@ private static Command CreateBlockSubcommand(ILogger logger, IAgent365ToolingSer
return command;
}
+ ///
+ /// Creates the package generation subcommand
+ ///
+ private static Command CreatePackageMCPServerSubCommand(ILogger logger, IAgent365ToolingService toolingService)
+ {
+ var command = new Command("package-mcp-server", "Generate MCP server package for submission on Microsoft admin center");
+
+ var serverNameOption = new Option("--server-name", "MCP server name") { IsRequired = true };
+ var developerNameOption = new Option("--developer-name", "Publisher/developer display name") { IsRequired = true };
+ var iconUrlOption = new Option("--icon-url", "Public URL to a PNG icon for the MCP server") { IsRequired = true };
+ var outputPathOption = new Option("--output-path", "Target directory for the generated ZIP package") { IsRequired = true };
+ var dryRunOption = new Option(name: "--dry-run", description: "Show what would be done without executing");
+ var configOption = new Option(["-c", "--config"], getDefaultValue: () => "a365.config.json", description: "Configuration file path");
+
+ command.AddOption(serverNameOption);
+ command.AddOption(developerNameOption);
+ command.AddOption(iconUrlOption);
+ command.AddOption(outputPathOption);
+ command.AddOption(dryRunOption);
+ command.AddOption(configOption);
+
+ command.SetHandler(async (serverName, developerName, iconUrl, outputPath, dryRun) =>
+ {
+ if (dryRun)
+ {
+ logger.LogInformation("[DRY RUN] Would query MCP servers management endpoint to fetch details of the MCP server");
+ logger.LogInformation("[DRY RUN] Fetch the icon from the provided url");
+ logger.LogInformation("[DRY RUN] Build the package content and put it in the target directory");
+ await Task.CompletedTask;
+ return;
+ }
+
+ logger.LogInformation("Starting package creation...");
+
+ try
+ {
+ var serverInfo = await toolingService.GetServerInfoAsync(serverName);
+ var manifest = PackageMCPServerHelper.GenerateManifestJson(serverInfo, developerName, logger);
+ var zipFilePath = PackageMCPServerHelper.BuildPackage(manifest, serverInfo, iconUrl, outputPath);
+ logger.LogInformation("Package was created successfully at {zipFilePath}", zipFilePath);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Package creation failed");
+ }
+
+ }, serverNameOption, developerNameOption, iconUrlOption, outputPathOption, dryRunOption);
+
+ return command;
+ }
+
///
/// Validates and sanitizes user input following Azure CLI security patterns
///
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs
index fc7ee694..e5abf293 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs
@@ -138,5 +138,55 @@ public static string[] GetAllScopes()
return ServerToScope.Values.Select(v => v.Scope).Distinct().OrderBy(s => s).ToArray();
}
}
-
+
+ // PackageMCPServer constants
+ public static class PackageMCPServer
+ {
+ public const string OutlinePngIconFileName = "outline.png";
+ public const string ColorPngIconFileName = "color.png";
+ public const string ManifestFileName = "manifest.json";
+ public const string TemplateManifestJson =
+ @"
+ {
+ ""$schema"": ""https://developer.microsoft.com/en-us/json-schemas/teams/vDevPreview/MicrosoftTeams.schema.json"",
+ ""manifestVersion"": ""devPreview"",
+ ""agentConnectors"": [
+ {
+ ""id"": ""11111111-1111-1111-1111-111111111112"",
+ ""displayName"": ""DUMMY_DISPLAY_NAME"",
+ ""description"": ""DUMMY_DESCRIPTION"",
+ ""toolSource"": {
+ ""remoteMcpServer"": {
+ ""mcpServerUrl"": ""https://example.com/mcpServer"",
+ ""authorization"": {
+ ""type"": ""None""
+ }
+ }
+ }
+ }
+ ],
+ ""version"": ""1.0.0"",
+ ""id"": ""11111111-1111-1111-1111-111111111112"",
+ ""developer"": {
+ ""name"": ""DUMMY_DEVELOPER"",
+ ""websiteUrl"": ""https://go.microsoft.com/fwlink/?linkid=2138949"",
+ ""privacyUrl"": ""https://go.microsoft.com/fwlink/?linkid=2138865"",
+ ""termsOfUseUrl"": ""https://go.microsoft.com/fwlink/?linkid=2138950""
+ },
+ ""name"": {
+ ""short"": ""DUMMY_SHORT_NAME"",
+ ""full"": ""DUMMY_FULL_NAME""
+ },
+ ""description"": {
+ ""short"": ""DUMMY_SHORT_DESCRIPTION"",
+ ""full"": ""DUMMY_FULL_DESCRIPTION""
+ },
+ ""icons"": {
+ ""outline"": ""outline.png"",
+ ""color"": ""color.png""
+ },
+ ""accentColor"": ""#E0F6FC""
+ }";
+ }
+
}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/PackageMCPServerHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/PackageMCPServerHelper.cs
new file mode 100644
index 00000000..c9dd6a63
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/PackageMCPServerHelper.cs
@@ -0,0 +1,178 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.IO.Compression;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using Microsoft.Agents.A365.DevTools.Cli.Constants;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Helpers
+{
+ public class PackageMCPServerHelper
+ {
+ ///
+ /// Generates a manifest JSON for an MCP server package.
+ ///
+ /// Server information to include in the manifest
+ /// Name of the developer/publisher
+ /// Logger
+ /// JSON string containing the manifest
+ public static string GenerateManifestJson(ServerInfo p, string developerName, ILogger logger)
+ {
+ JsonNode root;
+ try
+ {
+ root = JsonNode.Parse(McpConstants.PackageMCPServer.TemplateManifestJson) ?? new JsonObject();
+ }
+ catch
+ {
+ root = new JsonObject();
+ }
+
+ var obj = root as JsonObject ?? new JsonObject();
+
+ var displayName = p.McpServerDisplayName ?? string.Empty;
+ var shortDisplayName = displayName.Length <= 30 ? displayName : displayName.Substring(0, 30);
+ var fullDisplayName = displayName.Length <= 100 ? displayName : displayName.Substring(0, 100);
+ if (displayName.Length > 30)
+ {
+ logger.LogWarning("Short name truncated to 30 characters. Original '{Original}' -> '{Short}'",
+ displayName, shortDisplayName);
+ }
+ if (displayName.Length > 100)
+ {
+ logger.LogWarning("Full name truncated to 100 characters. Original '{Original}' -> '{Full}'",
+ displayName, fullDisplayName);
+ }
+
+ var description = p.McpServerDescription ?? string.Empty;
+ var shortDescription = description.Length <= 80 ? description : description.Substring(0, 80);
+ if (description.Length > 80)
+ {
+ logger.LogWarning("Short description truncated to 80 characters. Original '{Original}' -> '{Short}'",
+ description, shortDescription);
+ }
+
+ // Replace values.
+ if (obj["agentConnectors"] is JsonArray connectors && connectors.Count > 0 && connectors[0] is JsonObject c0)
+ {
+ Set(c0, "id", p.McpServerId);
+ Set(c0, "displayName", shortDisplayName);
+ Set(c0, "description", description);
+
+ if (c0["toolSource"]?["remoteMcpServer"] is JsonObject rs)
+ {
+ Set(rs, "mcpServerUrl", p.McpServerUrl);
+ }
+ }
+ Set(obj, "id", p.McpServerId);
+ var developerObj = obj["developer"] as JsonObject ?? (JsonObject)(obj["developer"] = new JsonObject());
+ var nameObj = obj["name"] as JsonObject ?? (JsonObject)(obj["name"] = new JsonObject());
+ var descriptionObj = obj["description"] as JsonObject ?? (JsonObject)(obj["description"] = new JsonObject());
+
+ Set(developerObj, "name", developerName);
+ Set(nameObj, "short", shortDisplayName);
+ Set(nameObj, "full", fullDisplayName);
+ Set(descriptionObj, "short", shortDescription);
+ Set(descriptionObj, "full", description);
+
+ return obj.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
+
+ static void Set(JsonNode? parent, string prop, string? value)
+ {
+ if (parent is JsonObject o)
+ {
+ o[prop] = value ?? string.Empty;
+ }
+ }
+ }
+
+ ///
+ /// The method to build the MCP package as a zip file.
+ ///
+ /// JSON content for the manifest.json file
+ /// Server information used for package naming
+ /// Public URL to download the icon from
+ /// Directory where the ZIP package will be created
+ /// Full path to the created ZIP file
+ public static string BuildPackage(string manifestJson, ServerInfo info, string iconUrl, string outputPath)
+ {
+ Directory.CreateDirectory(outputPath);
+
+ // Derive package name from server id
+ var baseName = "Package_" + info.McpServerId;
+
+ // Basic sanitization for file name.
+ var invalidChars = Path.GetInvalidFileNameChars();
+ var sb = new System.Text.StringBuilder(baseName.Length);
+ foreach (var ch in baseName)
+ {
+ sb.Append(invalidChars.Contains(ch) ? '_' : ch);
+ }
+ var safeName = sb.ToString();
+ var zipFilePath = Path.Combine(outputPath, $"{safeName}.zip");
+
+ // Download icon (both outline.png and color.png will use same bytes)
+ byte[] iconBytes;
+ using (var httpClient = new HttpClient())
+ {
+ using var response = httpClient.GetAsync(iconUrl, HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult();
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException($"Failed to download icon from '{iconUrl}'. HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
+ }
+
+ iconBytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
+ if (iconBytes.Length == 0)
+ {
+ throw new InvalidOperationException($"Downloaded icon from '{iconUrl}' is empty.");
+ }
+ }
+
+ using (var fileStream = new FileStream(zipFilePath, FileMode.Create, FileAccess.Write, FileShare.None))
+ {
+ using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false))
+ {
+ // manifest.json
+ WriteTextToArchive(zipArchive, McpConstants.PackageMCPServer.ManifestFileName, manifestJson);
+
+ // Icons
+ WriteByteToArchive(zipArchive, McpConstants.PackageMCPServer.OutlinePngIconFileName, iconBytes);
+ WriteByteToArchive(zipArchive, McpConstants.PackageMCPServer.ColorPngIconFileName, iconBytes);
+ }
+ }
+
+ return zipFilePath;
+ }
+
+ private static void WriteTextToArchive(ZipArchive zipArchive, string fileName, string text)
+ {
+ var entry = zipArchive.CreateEntry(fileName);
+ using var entryStream = entry.Open();
+ using var writer = new StreamWriter(entryStream);
+ writer.Write(text);
+ }
+
+ private static void WriteByteToArchive(ZipArchive zipArchive, string fileName, byte[] binary)
+ {
+ var entry = zipArchive.CreateEntry(fileName);
+ using var entryStream = entry.Open();
+ using var bw = new BinaryWriter(entryStream);
+ bw.Write(binary);
+ }
+
+ public sealed class ServerInfo
+ {
+ public string McpServerId { get; init; } = string.Empty;
+ public string McpServerDisplayName { get; init; } = string.Empty;
+ public string McpServerDescription { get; init; } = string.Empty;
+ public string McpServerUrl { get; init; } = string.Empty;
+ }
+ }
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs
index 651cc800..77fa6e67 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs
@@ -1,11 +1,13 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-using System.Net.Http;
-using System.Text.Json;
-using Microsoft.Extensions.Logging;
using Microsoft.Agents.A365.DevTools.Cli.Constants;
using Microsoft.Agents.A365.DevTools.Cli.Models;
using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;
+using Microsoft.Extensions.Logging;
+using System.Net.Http;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using static Microsoft.Agents.A365.DevTools.Cli.Helpers.PackageMCPServerHelper;
namespace Microsoft.Agents.A365.DevTools.Cli.Services;
@@ -211,6 +213,17 @@ private string BuildBlockMcpServerUrl(string environment, string serverName)
return $"{baseUrl}/agents/mcpServers/{serverName}/block";
}
+ ///
+ /// Builds URL for getting MCP server details
+ ///
+ /// Environment name
+ /// URL for get MCP server endpoint
+ private string BuildGetMCPServerUrl(string environment)
+ {
+ var baseUrl = BuildAgent365ToolsBaseUrl(environment);
+ return $"{baseUrl}/agents/servers/MCPManagement";
+ }
+
///
public async Task ListEnvironmentsAsync(CancellationToken cancellationToken = default)
{
@@ -603,5 +616,108 @@ public async Task BlockServerAsync(
return false;
}
}
+
+ ///
+ public async Task GetServerInfoAsync(string serverName, CancellationToken cancellationToken = default)
+ {
+ var endpointUrl = BuildGetMCPServerUrl(_environment);
+
+ _logger.LogInformation("Calling get MCP server for {ServerName}", serverName);
+ _logger.LogInformation("Environment: {Env}", _environment);
+ _logger.LogInformation("Endpoint URL: {Url}", endpointUrl);
+
+ // Get authentication token
+ var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment);
+ _logger.LogInformation("Acquiring access token for audience: {Audience}", audience);
+
+ var authToken = await _authService.GetAccessTokenAsync(audience);
+ if (string.IsNullOrWhiteSpace(authToken))
+ {
+ _logger.LogError("Failed to acquire authentication token");
+ throw new InvalidOperationException("Failed to acquire authentication token");
+ }
+ _logger.LogInformation("Successfully acquired access token");
+
+ // Create authenticated HTTP client
+ using var httpClient = Internal.HttpClientFactory.CreateAuthenticatedClient(authToken);
+
+ var requestObject = new
+ {
+ @params = new
+ {
+ name = "GetMCPServer",
+ arguments = new
+ {
+ mcpServerName = serverName
+ }
+ },
+ method = "tools/call",
+ id = "1",
+ jsonrpc = "2.0"
+ };
+
+ var json = JsonSerializer.Serialize(requestObject);
+
+ // Log request details
+ LogRequest("POST", endpointUrl, json);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, endpointUrl)
+ {
+ Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
+ };
+
+ // Add Accept headers
+ request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
+ request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));
+
+ // Send the request
+ var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogError("Failed to fetch MCP server details. Status: {Status}", response.StatusCode);
+ var errorContent = await response.Content.ReadAsStringAsync();
+ _logger.LogError("Error response: {Error}", errorContent);
+ throw new InvalidOperationException("Failed to fetch MCP server details");
+ }
+
+ var responseContent = await response.Content.ReadAsStringAsync();
+
+ _logger.LogInformation("Successfully received response from MCP servers management endpoint");
+
+ // Join all response data: lines (handles single or multi-line data segments)
+ var dataJson = string.Concat(
+ responseContent.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)
+ .Where(l => l.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
+ .Select(l => l.Substring(5).Trim()));
+
+ // Parse outer JSON-RPC
+ var root = JsonNode.Parse(dataJson)!;
+ var content = root["result"]?["content"]?.AsArray()
+ ?? throw new InvalidOperationException("Missing result.content");
+
+ // Find the first text chunk that contains inner JSON (starts with '{')
+ var innerJson = content
+ .Select(n => n?["text"]?.GetValue())
+ .FirstOrDefault(t => t is { } s && s.TrimStart().StartsWith("{"))
+ ?? throw new InvalidOperationException("Inner JSON not found in content[].text");
+
+ // Parse inner JSON and read server
+ var server = JsonNode.Parse(innerJson)!["server"]
+ ?? throw new InvalidOperationException("Missing 'server' object");
+
+ string Get(string name) =>
+ server[name]?.GetValue() is { } v && !string.IsNullOrWhiteSpace(v)
+ ? v
+ : throw new InvalidOperationException($"Missing/empty server.{name}");
+
+ return new ServerInfo
+ {
+ McpServerId = Get("id"),
+ McpServerDisplayName = Get("displayName"),
+ McpServerDescription = Get("description"),
+ McpServerUrl = Get("url")
+ };
+ }
}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs
index b3fcbb0e..605310b5 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Microsoft.Agents.A365.DevTools.Cli.Models;
+using static Microsoft.Agents.A365.DevTools.Cli.Helpers.PackageMCPServerHelper;
namespace Microsoft.Agents.A365.DevTools.Cli.Services;
@@ -71,5 +72,15 @@ Task ApproveServerAsync(
Task BlockServerAsync(
string serverName,
CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets MCP server information
+ ///
+ /// MCP server name
+ /// Cancellation token
+ /// ServerInfo
+ public Task GetServerInfoAsync(
+ string serverName,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs
index c17a5738..f0a62e12 100644
--- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs
@@ -41,7 +41,7 @@ public void CreateCommand_HasAllExpectedSubcommands()
var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService);
// Assert
- command.Subcommands.Should().HaveCount(6);
+ command.Subcommands.Should().HaveCount(7);
var subcommandNames = command.Subcommands.Select(sc => sc.Name).ToList();
subcommandNames.Should().Contain(new[]
@@ -51,7 +51,8 @@ public void CreateCommand_HasAllExpectedSubcommands()
"publish",
"unpublish",
"approve",
- "block"
+ "block",
+ "package-mcp-server"
});
}
@@ -179,6 +180,37 @@ public void UnpublishSubcommand_HasCorrectOptionsWithAliases()
serverOption!.Aliases.Should().Contain("-s");
}
+ [Fact]
+ public void PackageMcpServerSubcommand_HasCorrectOptions()
+ {
+ // Act
+ var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService);
+ var subcommand = command.Subcommands.First(sc => sc.Name == "package-mcp-server");
+
+ // Assert
+ subcommand.Description.Should().Be("Generate MCP server package for submission on Microsoft admin center");
+
+ var options = subcommand.Options.ToList();
+ options.Should().HaveCount(6); // serverName, developerName, iconUrl, outputPath, dry-run, config
+
+ var optionNames = options.Select(o => o.Name).ToList();
+ optionNames.Should().Contain("server-name");
+ optionNames.Should().Contain("developer-name");
+ optionNames.Should().Contain("icon-url");
+ optionNames.Should().Contain("output-path");
+ optionNames.Should().Contain("dry-run");
+ optionNames.Should().Contain("config");
+
+ options.First(o => o.Name == "server-name").IsRequired.Should().BeTrue();
+ options.First(o => o.Name == "developer-name").IsRequired.Should().BeTrue();
+ options.First(o => o.Name == "icon-url").IsRequired.Should().BeTrue();
+ options.First(o => o.Name == "output-path").IsRequired.Should().BeTrue();
+
+ // Config option keeps Azure CLI style short alias
+ var configOption = options.First(o => o.Name == "config");
+ configOption.Aliases.Should().Contain("-c");
+ }
+
[Fact]
public void ApproveSubcommand_IsImplementedWithCorrectOptions()
{