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() {