Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -763,6 +766,57 @@ private static Command CreateBlockSubcommand(ILogger logger, IAgent365ToolingSer
return command;
}

/// <summary>
/// Creates the package generation subcommand
/// </summary>
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<string>("--server-name", "MCP server name") { IsRequired = true };
var developerNameOption = new Option<string>("--developer-name", "Publisher/developer display name") { IsRequired = true };
var iconUrlOption = new Option<string>("--icon-url", "Public URL to a PNG icon for the MCP server") { IsRequired = true };
var outputPathOption = new Option<string>("--output-path", "Target directory for the generated ZIP package") { IsRequired = true };
var dryRunOption = new Option<bool>(name: "--dry-run", description: "Show what would be done without executing");
var configOption = new Option<string>(["-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;
}

/// <summary>
/// Validates and sanitizes user input following Azure CLI security patterns
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move this to its own file?

{
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""
}";
}

}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Generates a manifest JSON for an MCP server package.
/// </summary>
/// <param name="p">Server information to include in the manifest</param>
/// <param name="developerName">Name of the developer/publisher</param>
/// <param name="logger">Logger</param>
/// <returns>JSON string containing the manifest</returns>
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;
}
}
}

/// <summary>
/// The method to build the MCP package as a zip file.
/// </summary>
/// <param name="manifestJson">JSON content for the manifest.json file</param>
/// <param name="info">Server information used for package naming</param>
/// <param name="iconUrl">Public URL to download the icon from</param>
/// <param name="outputPath">Directory where the ZIP package will be created</param>
/// <returns>Full path to the created ZIP file</returns>
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;
}
}
}
Loading
Loading